134 lines
6.3 KiB
TypeScript
134 lines
6.3 KiB
TypeScript
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')
|
|
})
|