fix(storage): purge raw audio when its history entry or meeting is deleted

This commit is contained in:
Yun Chan 2026-09-28 02:16:22 +09:00
parent 4807a5283d
commit ed790e672b
10 changed files with 1073 additions and 39 deletions

View file

@ -0,0 +1,134 @@
import { drainAudioPurgeQueue, handleAudioPurgeRequest, type AudioPurgeQueuePort } from './handler.ts'
import { isPurgeableStorageKey, planPurge, type PurgeQueueEntry } from './policy.ts'
import { parseClaimedRows } from './queue-adapter.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function assertEquals(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual)
const e = JSON.stringify(expected)
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
}
const USER = '24000000-0000-4000-8000-000000000001'
const OTHER = '24000000-0000-4000-8000-000000000002'
function entry(id: number, storageKey: string, userId = USER, leaseToken = 'lease-1'): PurgeQueueEntry {
return { id, userId, storageKey, leaseToken }
}
class FakeQueue implements AudioPurgeQueuePort {
completed: Array<{ ids: number[]; leaseToken: string }> = []
constructor(private batches: PurgeQueueEntry[][]) {}
claim(): Promise<PurgeQueueEntry[]> {
return Promise.resolve(this.batches.shift() ?? [])
}
complete(ids: number[], leaseToken: string): Promise<number> {
this.completed.push({ ids, leaseToken })
return Promise.resolve(ids.length)
}
}
class FakeStorage {
removed: string[][] = []
constructor(private failing = false) {}
remove(paths: string[]): Promise<void> {
if (this.failing) return Promise.reject(new Error('storage_delete_failed:audio'))
this.removed.push(paths)
return Promise.resolve()
}
}
Deno.test('redteam r2-24: keys inside the owner prefix are purgeable', () => {
assert(isPurgeableStorageKey(USER, `${USER}/history/abc.wav`), 'desktop history key')
assert(isPurgeableStorageKey(USER, `${USER}/${'a'.repeat(64)}/memo.m4a`), 'mobile content-addressed key')
})
Deno.test('redteam r2-24: keys that could reach another user or escape the prefix are rejected', () => {
assert(!isPurgeableStorageKey(USER, `${OTHER}/history/abc.wav`), 'other user prefix')
assert(!isPurgeableStorageKey(USER, USER), 'bare prefix (whole folder)')
assert(!isPurgeableStorageKey(USER, `${USER}/../${OTHER}/x.wav`), 'dot-dot segment')
assert(!isPurgeableStorageKey(USER, `${USER}/./x.wav`), 'dot segment')
assert(!isPurgeableStorageKey(USER, `${USER}//x.wav`), 'empty segment')
assert(!isPurgeableStorageKey(USER, `${USER}/x.wav/`), 'trailing slash')
assert(!isPurgeableStorageKey(USER, `${USER}\\..\\x.wav`), 'backslash')
assert(!isPurgeableStorageKey(USER, `${USER}/x\u0000.wav`), 'control character')
assert(!isPurgeableStorageKey('not-a-uuid', 'not-a-uuid/x.wav'), 'non-uuid owner')
assert(!isPurgeableStorageKey(USER, `${USER}/${'a'.repeat(1100)}`), 'overlong key')
})
Deno.test('redteam r2-24: planPurge splits removable from rejected', () => {
const plan = planPurge([entry(1, `${USER}/history/a.wav`), entry(2, `${OTHER}/history/b.wav`)])
assertEquals(plan.removable.map((e) => e.id), [1], 'removable ids')
assertEquals(plan.rejected.map((e) => e.id), [2], 'rejected ids')
})
Deno.test('redteam r2-24: drain removes the objects, then completes the batch under its lease', async () => {
const queue = new FakeQueue([[
entry(1, `${USER}/history/a.wav`),
entry(2, `${USER}/meetings/m.wav`),
entry(3, `${OTHER}/history/stolen.wav`),
]])
const storage = new FakeStorage()
const summary = await drainAudioPurgeQueue({ queue, storage })
assertEquals(storage.removed, [[`${USER}/history/a.wav`, `${USER}/meetings/m.wav`]], 'removed paths')
assertEquals(queue.completed, [{ ids: [3, 1, 2], leaseToken: 'lease-1' }], 'completed under lease')
assertEquals(summary, { batches: 1, claimed: 3, removed: 2, rejected: 1, failed: 0, completed: 3 }, 'summary')
})
Deno.test('redteam r2-24: a storage failure never completes the unremoved entries', async () => {
const queue = new FakeQueue([[entry(1, `${USER}/history/a.wav`), entry(2, `${OTHER}/x.wav`)]])
const summary = await drainAudioPurgeQueue({ queue, storage: new FakeStorage(true) })
assertEquals(queue.completed, [{ ids: [2], leaseToken: 'lease-1' }], 'only the rejected entry completes')
assertEquals(summary.failed, 1, 'failed count')
assertEquals(summary.removed, 0, 'nothing removed')
})
Deno.test('redteam r2-24: drain keeps claiming full batches and stops on an empty claim', async () => {
const full = Array.from({ length: 100 }, (_, i) => entry(i + 1, `${USER}/history/${i}.wav`))
const queue = new FakeQueue([full, [entry(101, `${USER}/history/last.wav`, USER, 'lease-2')]])
const storage = new FakeStorage()
const summary = await drainAudioPurgeQueue({ queue, storage })
assertEquals(summary.batches, 2, 'two batches')
assertEquals(summary.completed, 101, 'all completed')
assertEquals(queue.completed.map((c) => c.leaseToken), ['lease-1', 'lease-2'], 'each batch under its own lease')
assertEquals(storage.removed.map((b) => b.length), [100, 1], 'API-sized removal batches')
})
Deno.test('redteam r2-24: parseClaimedRows accepts RPC rows and skips malformed ones', () => {
const rows = parseClaimedRows([
{ id: 7, user_id: USER, storage_key: `${USER}/a.wav`, lease_token: 'l' },
{ id: '8', user_id: USER, storage_key: `${USER}/b.wav`, lease_token: 'l' },
{ id: 0, user_id: USER, storage_key: `${USER}/c.wav`, lease_token: 'l' },
{ id: 9, user_id: USER, storage_key: null, lease_token: 'l' },
'junk',
])
assertEquals(rows.map((r) => r.id), [7, 8], 'parsed ids')
assertEquals(parseClaimedRows(null), [], 'null data')
})
Deno.test('redteam r2-24: only an authorized POST runs the purge', async () => {
let ran = 0
const deps = (authorized: boolean) => ({
authorize: () => Promise.resolve(authorized),
createDeps: () => {
ran += 1
return { queue: new FakeQueue([]), storage: new FakeStorage() }
},
})
const get = await handleAudioPurgeRequest(new Request('http://x/audio-purge'), deps(true))
assertEquals(get.status, 405, 'GET rejected')
const denied = await handleAudioPurgeRequest(new Request('http://x/audio-purge', { method: 'POST' }), deps(false))
assertEquals(denied.status, 401, 'unauthorized rejected')
assertEquals(ran, 0, 'no work before authorization')
const ok = await handleAudioPurgeRequest(new Request('http://x/audio-purge', { method: 'POST' }), deps(true))
assertEquals(ok.status, 200, 'authorized POST succeeds')
assertEquals(ran, 1, 'purge ran once')
})