diff --git a/test/backup-api.test.js b/test/backup-api.test.js new file mode 100644 index 0000000..f254a0b --- /dev/null +++ b/test/backup-api.test.js @@ -0,0 +1,452 @@ +// 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); + +/** + * @param {{withTarget?: boolean, withKey?: boolean, state?: Object|null, key?: Uint8Array}} [options] + */ +function createBackupApi({ + withTarget = true, + withKey = true, + state = null, + key = KEY, +} = {}) { + /** @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 { seq: 1, cid: 'bafycommit', rev: 'rev1' }; + }, + 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: async () => new Uint8Array([1, 2, 3]), + // 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('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/); + }); +}); diff --git a/test/xrpc-repo.test.js b/test/xrpc-repo.test.js index 75053d9..b053f06 100644 --- a/test/xrpc-repo.test.js +++ b/test/xrpc-repo.test.js @@ -502,3 +502,254 @@ describe('applyWrites', () => { expect(response.status).toBe(401); }); }); + +describe('applyWrites operations', () => { + it('updates and deletes in the same commit as a create', async () => { + const { repo, records, commits, unlinked } = createRepo(); + await call(repo, CREATE, { + auth: FULL, + body: { + repo: DID, + collection: COLLECTION, + rkey: 'old', + record: { text: 'first' }, + }, + }); + + const response = await call(repo, APPLY, { + auth: FULL, + body: { + repo: DID, + writes: [ + { + $type: 'com.atproto.repo.applyWrites#update', + collection: COLLECTION, + rkey: 'old', + value: { text: 'second' }, + }, + { + $type: 'com.atproto.repo.applyWrites#create', + collection: COLLECTION, + rkey: 'fresh', + value: { text: 'new' }, + }, + { + $type: 'com.atproto.repo.applyWrites#delete', + collection: COLLECTION, + rkey: 'fresh', + }, + ], + }, + }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect( + body.results.map((/** @type {{$type: string}} */ r) => r.$type), + ).toEqual([ + 'com.atproto.repo.applyWrites#updateResult', + 'com.atproto.repo.applyWrites#createResult', + 'com.atproto.repo.applyWrites#deleteResult', + ]); + // The batch is one commit carrying all three actions. + expect(commits[1].map((o) => o.action)).toEqual([ + 'update', + 'create', + 'delete', + ]); + expect(records.has(`at://${DID}/${COLLECTION}/fresh`)).toBe(false); + expect(unlinked).toContain(`at://${DID}/${COLLECTION}/fresh`); + }); + + it('needs an rkey to update', async () => { + const { repo } = createRepo(); + const response = await call(repo, APPLY, { + auth: FULL, + body: { + repo: DID, + writes: [ + { + $type: 'com.atproto.repo.applyWrites#update', + collection: COLLECTION, + value: {}, + }, + ], + }, + }); + + expect(response.status).toBe(400); + expect((await response.json()).message).toMatch(/rkey required for update/); + }); + + it('needs an rkey to delete', async () => { + const { repo } = createRepo(); + const response = await call(repo, APPLY, { + auth: FULL, + body: { + repo: DID, + writes: [ + { + $type: 'com.atproto.repo.applyWrites#delete', + collection: COLLECTION, + }, + ], + }, + }); + + expect(response.status).toBe(400); + expect((await response.json()).message).toMatch(/rkey required for delete/); + }); + + it('refuses a write type it does not know', async () => { + const { repo } = createRepo(); + const response = await call(repo, APPLY, { + auth: FULL, + body: { + repo: DID, + writes: [{ $type: 'com.atproto.repo.applyWrites#frobnicate' }], + }, + }); + + expect(response.status).toBe(400); + expect((await response.json()).message).toMatch(/Unknown write operation/); + }); + + it('needs writes to be an array', async () => { + const { repo } = createRepo(); + const response = await call(repo, APPLY, { + auth: FULL, + body: { repo: DID, writes: 'nope' }, + }); + + expect(response.status).toBe(400); + expect((await response.json()).message).toMatch(/must be an array/); + }); + + it('refuses a repo that is not this one', async () => { + const { repo } = createRepo(); + const response = await call(repo, APPLY, { + auth: FULL, + body: { repo: 'did:plc:someoneelse', writes: [] }, + }); + + expect(response.status).toBe(403); + }); + + it('needs an account', async () => { + const { repo } = createRepo({ did: null }); + const response = await call(repo, APPLY, { + auth: FULL, + body: { repo: DID, writes: [] }, + }); + + expect(response.status).toBe(400); + expect((await response.json()).message).toMatch(/not initialized/); + }); + + it('passes on the reason an account is inactive', async () => { + const { repo } = createRepo({ + inactive: Response.json({ error: 'AccountDeactivated' }, { status: 400 }), + }); + const response = await call(repo, APPLY, { + auth: FULL, + body: { repo: DID, writes: [] }, + }); + + expect((await response.json()).error).toBe('AccountDeactivated'); + }); + + it('reports a schema failure and writes nothing', async () => { + const { repo, records } = createRepo({ + resolver: { + async assertValid() { + throw new Error('text must be a string'); + }, + }, + }); + const response = await call(repo, APPLY, { + auth: FULL, + body: { + repo: DID, + writes: [ + { + $type: 'com.atproto.repo.applyWrites#create', + collection: COLLECTION, + value: { text: 1 }, + }, + ], + }, + }); + + expect(response.status).toBe(400); + expect((await response.json()).error).toBe('InvalidRecord'); + expect(records.size).toBe(0); + }); +}); + +describe('putRecord and deleteRecord guards', () => { + it('needs a session to put', async () => { + const { repo } = createRepo(); + const response = await call(repo, PUT, { + body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} }, + }); + + expect(response.status).toBe(401); + }); + + it('refuses a read-only server on put', async () => { + const { repo } = createRepo({ readOnly: true }); + const response = await call(repo, PUT, { + auth: FULL, + body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} }, + }); + + expect(response.status).toBe(401); + }); + + it('needs a session to delete', async () => { + const { repo } = createRepo(); + const response = await call(repo, DELETE, { + body: { repo: DID, collection: COLLECTION, rkey: 'a' }, + }); + + expect(response.status).toBe(401); + }); + + it('refuses a read-only server on delete', async () => { + const { repo } = createRepo({ readOnly: true }); + const response = await call(repo, DELETE, { + auth: FULL, + body: { repo: DID, collection: COLLECTION, rkey: 'a' }, + }); + + expect(response.status).toBe(401); + }); + + it('refuses a collection the scope does not cover on delete', async () => { + const { repo } = createRepo(); + const response = await call(repo, DELETE, { + auth: { did: DID, scope: 'repo:app.bsky.feed.like' }, + body: { repo: DID, collection: COLLECTION, rkey: 'a' }, + }); + + expect(response.status).toBe(403); + }); +}); + +describe('getRecord and listRecords guards', () => { + it('needs repo, collection and rkey to get', async () => { + const { repo } = createRepo(); + const response = await call(repo, `${GET}?repo=${DID}`); + + expect(response.status).toBe(400); + expect((await response.json()).message).toMatch(/Missing required/); + }); + + it('needs repo and collection to list', async () => { + const { repo } = createRepo(); + const response = await call(repo, `${LIST}?repo=${DID}`); + + expect(response.status).toBe(400); + expect((await response.json()).message).toMatch(/Missing required/); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 1ade963..3aee604 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -50,9 +50,9 @@ export default defineConfig({ // the floor while one module is far below it. Each module gets its own // entry. The two spaces entries below are separate for the same reason. 'packages/core/src/handlers/account-backup.js': { - statements: 70, - branches: 52, - functions: 65, + statements: 97, + branches: 90, + functions: 100, }, 'packages/core/src/handlers/xrpc-sync.js': { statements: 95, @@ -63,8 +63,8 @@ export default defineConfig({ // them differently between releases. Statements and functions do not // move that way. 'packages/core/src/handlers/xrpc-repo.js': { - statements: 70, - branches: 59, + statements: 88, + branches: 79, functions: 100, }, 'packages/core/src/handlers/xrpc-identity.js': {