// Backup account API tests - the settings the page can change, and what it reads // // accountApi arrives through the context, so a stand-in that keeps its contract // (call the handler with a did and a parsed body, JSON on return, 400 on throw) // reaches these three routes without a session. import { describe, expect, it } from 'vitest'; import { defaultBackupState } from '../packages/core/src/backup.js'; import { base64UrlEncode } from '../packages/core/src/crypto.js'; import { createBackupHandlers } from '../packages/core/src/handlers/account-backup.js'; const DID = 'did:plc:backupapiaccount234567'; const KEY = new Uint8Array(32).fill(3); /** @typedef {{seq: number, cid: string, rev: string}} Head */ /** * @param {{withTarget?: boolean, withKey?: boolean, state?: Object|null, key?: Uint8Array, heads?: () => Head, buildFullRepoCar?: (cid: string) => Promise}} [options] */ function createBackupApi({ withTarget = true, withKey = true, state = null, key = KEY, heads = () => ({ seq: 1, cid: 'bafycommit', rev: 'rev1' }), buildFullRepoCar = async () => new Uint8Array([1, 2, 3]), } = {}) { /** @type {{value: Object|null}} */ const stored = { value: state }; /** @type {Map} */ const objects = new Map(); const target = { async put(/** @type {string} */ k, /** @type {Uint8Array} */ d) { objects.set(k, d); }, async get(/** @type {string} */ k) { return objects.get(k) || null; }, async list(/** @type {string} */ prefix) { return [...objects.keys()].filter((k) => k.startsWith(prefix)); }, async delete(/** @type {string} */ k) { objects.delete(k); }, }; const handlers = createBackupHandlers({ actorStorage: /** @type {any} */ ({ async getBackupState() { return stored.value; }, async setBackupState(/** @type {Object} */ next) { stored.value = next; }, async getPrivateKey() { return withKey ? key : null; }, async getLatestCommit() { return heads(); }, async getHandle() { return 'alice.example.com'; }, async listBlobs() { return { cids: [], cursor: null }; }, }), blobs: /** @type {any} */ ({ async get() { return null; }, }), backupTarget: withTarget ? /** @type {any} */ (target) : null, hostname: 'pds.example.com', getDid: async () => DID, buildFullRepoCar, // Keeps accountApi's contract: parse, delegate, JSON out, 400 on throw. accountApi: async (request, _url, handler) => { const body = request.method === 'POST' ? await request.json().catch(() => ({})) : {}; try { const result = await handler(DID, body); return result instanceof Response ? result : Response.json(result, { status: 200 }); } catch (err) { const message = err instanceof Error ? err.message : String(err); return Response.json({ error: message }, { status: 400 }); } }, }); return { handlers, stored, objects }; } const READ = '/account/api/backups'; const SETTINGS = '/account/api/backups/settings'; const RUN = '/account/api/backups/run'; /** * @param {ReturnType} handlers * @param {string} path * @param {Object} [body] */ async function call(handlers, path, body) { const route = handlers.routes[path]; const url = new URL(`https://pds.example.com${path}`); /** @type {RequestInit} */ const init = { method: body ? 'POST' : 'GET' }; if (body) init.body = JSON.stringify(body); const handler = /** @type {(request: Request, url: URL, auth: null) => Promise} */ ( route.handler ); return handler(new Request(url, init), url, null); } describe('GET backups', () => { it('reports defaults on a server that has never run one', async () => { const { handlers } = createBackupApi(); const body = await (await call(handlers, READ)).json(); expect(body.available).toBe(true); expect(body.settings.enabled).toBe(false); expect(body.settings.keyBackup).toBe(null); expect(body.runs).toEqual([]); }); it('reports unavailable without a target', async () => { const { handlers } = createBackupApi({ withTarget: false }); const body = await (await call(handlers, READ)).json(); expect(body.available).toBe(false); }); it('hides nextRunAt while backups are off', async () => { const state = defaultBackupState(); state.nextRunAt = 1800000000000; const { handlers } = createBackupApi({ state }); const body = await (await call(handlers, READ)).json(); expect(body.nextRunAt).toBe(null); }); }); describe('POST backups/settings', () => { it('turns backups on and schedules the first run', async () => { const { handlers, stored } = createBackupApi(); const body = await ( await call(handlers, SETTINGS, { enabled: true }) ).json(); expect(body.settings.enabled).toBe(true); expect(body.nextRunAt).toBeGreaterThan(0); expect(/** @type {any} */ (stored.value).config.enabled).toBe(true); }); it('clears the schedule when turned off', async () => { const { handlers } = createBackupApi(); await call(handlers, SETTINGS, { enabled: true }); const body = await ( await call(handlers, SETTINGS, { enabled: false }) ).json(); expect(body.nextRunAt).toBe(null); }); it('takes an interval and a retention count', async () => { const { handlers } = createBackupApi(); const body = await ( await call(handlers, SETTINGS, { intervalHours: 12, retain: 7 }) ).json(); expect(body.settings.intervalHours).toBe(12); expect(body.settings.retain).toBe(7); }); it('holds the interval between 1 and 720 hours', async () => { const { handlers } = createBackupApi(); for (const intervalHours of [0, 721, 'nope']) { const response = await call(handlers, SETTINGS, { intervalHours }); expect(response.status, String(intervalHours)).toBe(400); expect((await response.json()).error).toMatch(/between 1 and 720/); } }); it('holds retention between 1 and 50 snapshots', async () => { const { handlers } = createBackupApi(); for (const retain of [0, 51, 2.5]) { const response = await call(handlers, SETTINGS, { retain }); expect(response.status, String(retain)).toBe(400); expect((await response.json()).error).toMatch(/between 1 and 50/); } }); it('refuses settings without a target', async () => { const { handlers } = createBackupApi({ withTarget: false }); const response = await call(handlers, SETTINGS, { enabled: true }); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/No backup target/); }); // The browser derives the AES key, so the server only ever sees key, salt and // count. It seals the signing key with them. it('seals the signing key from a derived key', async () => { const { handlers } = createBackupApi(); const body = await ( await call(handlers, SETTINGS, { keyDerived: { key: base64UrlEncode(new Uint8Array(32).fill(9)), salt: base64UrlEncode(new Uint8Array(16).fill(1)), iterations: 600000, }, }) ).json(); expect(body.settings.keyBackup).toBeTruthy(); expect(body.settings.keyBackup.stale).toBe(false); }); it('takes the sealed key back out', async () => { const { handlers } = createBackupApi(); await call(handlers, SETTINGS, { keyDerived: { key: base64UrlEncode(new Uint8Array(32).fill(9)), salt: base64UrlEncode(new Uint8Array(16).fill(1)), iterations: 600000, }, }); const body = await ( await call(handlers, SETTINGS, { removeKeyBackup: true }) ).json(); expect(body.settings.keyBackup).toBe(null); }); it('needs a key and salt that are strings', async () => { const { handlers } = createBackupApi(); const response = await call(handlers, SETTINGS, { keyDerived: { key: 123, salt: 'abc', iterations: 600000 }, }); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/base64url key and salt/); }); it('holds the iteration count in range', async () => { const { handlers } = createBackupApi(); for (const iterations of [99999, 10000001, 1.5]) { const response = await call(handlers, SETTINGS, { keyDerived: { key: base64UrlEncode(new Uint8Array(32)), salt: base64UrlEncode(new Uint8Array(16)), iterations, }, }); expect(response.status, String(iterations)).toBe(400); expect((await response.json()).error).toMatch(/iterations out of range/); } }); it('needs 32 key bytes and at least 8 of salt', async () => { const { handlers } = createBackupApi(); const response = await call(handlers, SETTINGS, { keyDerived: { key: base64UrlEncode(new Uint8Array(16)), salt: base64UrlEncode(new Uint8Array(4)), iterations: 600000, }, }); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/32 bytes, salt at least 8/); }); it('cannot seal a key the server does not have', async () => { const { handlers } = createBackupApi({ withKey: false }); const response = await call(handlers, SETTINGS, { keyDerived: { key: base64UrlEncode(new Uint8Array(32)), salt: base64UrlEncode(new Uint8Array(16)), iterations: 600000, }, }); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/no signing key/); }); }); describe('the scheduler', () => { it('does nothing without a target', async () => { const { handlers } = createBackupApi({ withTarget: false }); expect(await handlers.maybeRunScheduledBackup()).toBe(null); }); it('does nothing while backups are off', async () => { const { handlers } = createBackupApi(); expect(await handlers.maybeRunScheduledBackup()).toBe(null); }); it('waits until the run is due', async () => { const { handlers } = createBackupApi(); await call(handlers, SETTINGS, { enabled: true, intervalHours: 24 }); const state = await handlers.readBackupState(); // Due one interval out, so a tick before that mark does nothing. expect( await handlers.maybeRunScheduledBackup(Number(state.nextRunAt) - 1000), ).toBe(null); }); it('leaves a run another isolate is still beating for', async () => { const { handlers } = createBackupApi(); await call(handlers, SETTINGS, { enabled: true }); const state = await handlers.readBackupState(); const now = Number(state.nextRunAt) + 1000; state.runs = [ /** @type {any} */ ({ id: 'other', status: 'running', startedAt: new Date(now).toISOString(), heartbeatAt: new Date(now).toISOString(), }), ]; await handlers.writeBackupState(state); expect(await handlers.maybeRunScheduledBackup(now)).toBe(null); }); it('runs when one is due', async () => { const { handlers } = createBackupApi(); await call(handlers, SETTINGS, { enabled: true }); const state = await handlers.readBackupState(); const run = await handlers.maybeRunScheduledBackup( Number(state.nextRunAt) + 1000, ); expect(run?.trigger).toBe('scheduled'); expect(run?.status).toBe('ok'); }); }); describe('pruneBackups', () => { it('keeps only the newest snapshots', async () => { const { handlers, objects } = createBackupApi(); for (const stamp of ['2026-01-01', '2026-01-02', '2026-01-03']) { objects.set(`snapshots/${stamp}/repo.car`, new Uint8Array([1])); objects.set( `snapshots/${stamp}/manifest.json`, new TextEncoder().encode(JSON.stringify({ blobs: [] })), ); } await handlers.pruneBackups(2); const stamps = [...objects.keys()] .filter((k) => k.startsWith('snapshots/')) .map((k) => k.split('/')[1]); expect([...new Set(stamps)].sort()).toEqual(['2026-01-02', '2026-01-03']); }); it('deletes blobs no kept manifest references', async () => { const { handlers, objects } = createBackupApi(); objects.set( 'snapshots/2026-01-01/manifest.json', new TextEncoder().encode(JSON.stringify({ blobs: ['keepme'] })), ); objects.set('blobs/keepme', new Uint8Array([1])); objects.set('blobs/orphan', new Uint8Array([2])); await handlers.pruneBackups(1); expect(objects.has('blobs/keepme')).toBe(true); expect(objects.has('blobs/orphan')).toBe(false); }); // Better to hold garbage than to reap a blob a live snapshot still needs. it('reaps nothing when a kept manifest will not parse', async () => { const { handlers, objects } = createBackupApi(); objects.set( 'snapshots/2026-01-01/manifest.json', new TextEncoder().encode('not json'), ); objects.set('blobs/orphan', new Uint8Array([2])); await handlers.pruneBackups(1); expect(objects.has('blobs/orphan')).toBe(true); }); it('reaps nothing when no kept manifest is readable at all', async () => { const { handlers, objects } = createBackupApi(); objects.set('snapshots/2026-01-01/repo.car', new Uint8Array([1])); objects.set('blobs/orphan', new Uint8Array([2])); await handlers.pruneBackups(1); expect(objects.has('blobs/orphan')).toBe(true); }); it('does nothing without a target', async () => { const { handlers, objects } = createBackupApi({ withTarget: false }); await handlers.pruneBackups(1); expect(objects.size).toBe(0); }); }); describe('POST backups/run', () => { it('answers with the finished run', async () => { const { handlers, objects } = createBackupApi(); const body = await (await call(handlers, RUN, {})).json(); expect(body.status).toBe('ok'); expect(body.trigger).toBe('manual'); expect(objects.has(`${body.snapshot}/manifest.json`)).toBe(true); expect(objects.has(`${body.snapshot}/repo.car`)).toBe(true); }); it('refuses a second run while one is going', async () => { const { handlers } = createBackupApi(); const first = handlers.runBackup('manual'); await expect(handlers.runBackup('manual')).rejects.toThrow( /already running/, ); await first; }); it('reports a stale sealed key as stale', async () => { const { handlers } = createBackupApi(); await call(handlers, SETTINGS, { keyDerived: { key: base64UrlEncode(new Uint8Array(32).fill(9)), salt: base64UrlEncode(new Uint8Array(16).fill(1)), iterations: 600000, }, }); // A rotated signing key no longer matches the fingerprint that was sealed. const rotated = createBackupApi({ state: await handlers.readBackupState(), key: new Uint8Array(32).fill(4), }); const view = await rotated.handlers.publicBackupView( await rotated.handlers.readBackupState(), ); expect(view.settings.keyBackup?.stale).toBe(true); }); it('snapshots the new head when a commit lands during the walk', async () => { let seq = 1; const head = () => ({ seq, cid: `bafycommit${seq}`, rev: `rev${seq}` }); const { handlers, objects } = createBackupApi({ heads: head, buildFullRepoCar: async (cid) => { if (cid !== 'bafycommit2') { seq = 2; throw new Error(`Missing block: of ${cid}`); } return new Uint8Array([2]); }, }); const body = await (await call(handlers, RUN, {})).json(); expect(body.status).toBe('ok'); const manifest = JSON.parse( new TextDecoder().decode(objects.get(`${body.snapshot}/manifest.json`)), ); expect(manifest.commit.cid).toBe('bafycommit2'); }); it('fails a snapshot whose head has not moved', async () => { const { handlers } = createBackupApi({ buildFullRepoCar: async () => { throw new Error('Missing block: x'); }, }); const body = await (await call(handlers, RUN, {})).json(); expect(body.status).toBe('error'); }); it('gives up on a head that keeps moving', async () => { let seq = 1; const { handlers } = createBackupApi({ heads: () => ({ seq, cid: `bafycommit${seq}`, rev: `rev${seq}` }), buildFullRepoCar: async () => { seq += 1; throw new Error('Missing block: x'); }, }); const body = await (await call(handlers, RUN, {})).json(); expect(body.status).toBe('error'); expect(seq).toBe(4); }); it('is a 400 with no target to write to', async () => { const { handlers } = createBackupApi({ withTarget: false }); const response = await call(handlers, RUN, {}); expect(response.status).toBe(400); expect((await response.json()).error).toMatch(/No backup target/); }); });