// Repo handler tests - record writes, scope checks, validation, applyWrites import { describe, expect, it } from 'vitest'; import { createRepoHandlers } from '../packages/core/src/handlers/xrpc-repo.js'; import { cborDecode } from '../packages/core/src/repo.js'; const DID = 'did:plc:repotestaccount23456'; const COLLECTION = 'app.bsky.feed.post'; const FULL = { did: DID, scope: 'com.atproto.access' }; /** * @param {{ * did?: string|null, readOnly?: boolean, inactive?: Response|null, * resolver?: {assertValid: (nsid: string, record: Object) => Promise}|null, * proxied?: Response|null, * }} [options] */ function createRepo({ did = DID, readOnly = false, inactive = null, resolver = null, proxied = null, } = {}) { /** @type {Map} */ const records = new Map(); /** @type {Map} */ const blocks = new Map(); /** @type {Array<{blobCid: string, uri: string}>} */ const blobLinks = []; /** @type {string[]} */ const unlinked = []; /** @type {Array>} */ const commits = []; /** @type {Array<{did: string, olderThan: number, only?: string[]}>} */ const reaped = []; let seq = 0; const actorStorage = { records, blocks, blobLinks, unlinked, async getHandle() { return 'alice.example.com'; }, async listAllRecords() { return [...records.keys()].map((uri) => ({ key: uri.split(`${DID}/`)[1], uri, })); }, async putBlock(/** @type {string} */ cid, /** @type {Uint8Array} */ data) { blocks.set(cid, data); }, async putRecord( /** @type {string} */ uri, /** @type {string} */ cid, /** @type {string} */ _collection, /** @type {string} */ _rkey, /** @type {Uint8Array} */ value, ) { records.set(uri, { cid, value }); }, async getRecord(/** @type {string} */ uri) { return records.get(uri) || null; }, async deleteRecord(/** @type {string} */ uri) { records.delete(uri); }, async listRecords( /** @type {string} */ collection, /** @type {string|null} */ _cursor, /** @type {number} */ limit, /** @type {boolean|undefined} */ reverse, ) { const rows = [...records.entries()] .filter(([uri]) => uri.includes(`/${collection}/`)) .sort(([a], [b]) => (a < b ? -1 : 1)); if (reverse) rows.reverse(); return { records: rows .slice(0, limit) .map(([uri, r]) => ({ uri, cid: r.cid, value: r.value })), cursor: null, }; }, async linkBlobToRecord( /** @type {string} */ blobCid, /** @type {string} */ uri, ) { blobLinks.push({ blobCid, uri }); }, async unlinkBlobsFromRecord(/** @type {string} */ uri) { unlinked.push(uri); }, async getLatestCommit() { return seq === 0 ? null : { seq, cid: `bafycommit${seq}`, rev: `rev${seq}` }; }, }; const repo = createRepoHandlers({ actorStorage: /** @type {any} */ (actorStorage), lexiconResolver: /** @type {any} */ (resolver), readOnly, getDid: async () => did, readOnlyError: () => Response.json( { error: 'AuthenticationRequired', message: 'This PDS is read-only' }, { status: 401 }, ), withRepoLock: (fn) => fn(), createCommit: async (_did, ops) => { commits.push(ops); seq += 1; return {}; }, checkAccountActive: async () => inactive, readJsonBounded: async (request) => request.json(), handleProxyHeader: async () => proxied, reapOrphanedBlobs: async (d, olderThan, only) => { reaped.push({ did: d, olderThan, only }); }, }); return { repo, actorStorage, records, commits, blobLinks, unlinked, reaped }; } /** * @param {ReturnType} repo * @param {string} path * @param {{body?: Object, auth?: {did: string, scope: string}|null}} [options] */ async function call(repo, path, { body, auth = null } = {}) { const route = repo.routes[path.split('?')[0]]; 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 request = new Request(url, init); const handler = /** @type {(request: Request, url: URL, auth: unknown) => Promise} */ ( route.handler ); return handler(request, url, auth); } const DESCRIBE = '/xrpc/com.atproto.repo.describeRepo'; const CREATE = '/xrpc/com.atproto.repo.createRecord'; const GET = '/xrpc/com.atproto.repo.getRecord'; const LIST = '/xrpc/com.atproto.repo.listRecords'; const PUT = '/xrpc/com.atproto.repo.putRecord'; const APPLY = '/xrpc/com.atproto.repo.applyWrites'; const DELETE = '/xrpc/com.atproto.repo.deleteRecord'; describe('describeRepo', () => { it('lists the collections the repo holds', async () => { const { repo } = createRepo(); await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, record: { text: 'a' } }, }); const body = await (await call(repo, DESCRIBE)).json(); expect(body.did).toBe(DID); expect(body.handle).toBe('alice.example.com'); expect(body.handleIsCorrect).toBe(true); expect(body.collections).toEqual([COLLECTION]); }); it('is a RepoNotFound for another repo', async () => { const { repo } = createRepo(); const response = await call(repo, `${DESCRIBE}?repo=did:plc:someoneelse`); expect(response.status).toBe(404); expect((await response.json()).error).toBe('RepoNotFound'); }); }); describe('createRecord', () => { it('stores the record, links its blobs, and commits once', async () => { const { repo, records, commits, blobLinks } = createRepo(); const response = await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, record: { text: 'hello', image: { $type: 'blob', ref: { $link: 'bafkblob1' } }, }, }, }); const body = await response.json(); expect(response.status).toBe(200); expect(body.uri).toMatch(new RegExp(`^at://${DID}/${COLLECTION}/`)); expect(records.get(body.uri)?.cid).toBe(body.cid); expect(commits).toHaveLength(1); expect(commits[0][0].action).toBe('create'); expect(blobLinks).toEqual([{ blobCid: 'bafkblob1', uri: body.uri }]); expect(body.commit.rev).toBe('rev1'); }); it('sets $type from the collection', async () => { const { repo, records } = createRepo(); const body = await ( await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, record: { text: 'a' } }, }) ).json(); const stored = cborDecode( /** @type {Uint8Array} */ (records.get(body.uri)?.value), ); expect(stored.$type).toBe(COLLECTION); }); it('takes a caller-supplied rkey', async () => { const { repo } = createRepo(); const body = await ( await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'chosen', record: { text: 'a' }, }, }) ).json(); expect(body.uri).toBe(`at://${DID}/${COLLECTION}/chosen`); }); it('refuses a key the repo already holds, and keeps the record', async () => { const { repo, records, commits } = createRepo(); const body = { repo: DID, collection: COLLECTION, rkey: 'taken' }; const first = await ( await call(repo, CREATE, { auth: FULL, body: { ...body, record: { text: 'first' } }, }) ).json(); const response = await call(repo, CREATE, { auth: FULL, body: { ...body, record: { text: 'second' } }, }); expect(response.status).toBe(400); expect(await response.json()).toEqual({ error: 'InvalidRequest', message: `Record already exists: ${first.uri}`, }); expect(records.get(first.uri)?.cid).toBe(first.cid); expect(commits).toHaveLength(1); }); it('needs a session', async () => { const { repo } = createRepo(); const response = await call(repo, CREATE, { body: { repo: DID, collection: COLLECTION, record: {} }, }); expect(response.status).toBe(401); }); it('refuses a read-only server', async () => { const { repo } = createRepo({ readOnly: true }); const response = await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, record: {} }, }); expect(response.status).toBe(401); expect((await response.json()).message).toMatch(/read-only/); }); 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, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, record: {} }, }); expect((await response.json()).error).toBe('AccountDeactivated'); }); it('refuses a repo that is not this one', async () => { const { repo } = createRepo(); const response = await call(repo, CREATE, { auth: FULL, body: { repo: 'did:plc:someoneelse', collection: COLLECTION, record: {}, }, }); expect(response.status).toBe(403); expect((await response.json()).message).toMatch(/Not authorized/); }); it('refuses a collection the scope does not cover', async () => { const { repo, records } = createRepo(); const response = await call(repo, CREATE, { auth: { did: DID, scope: 'repo:app.bsky.feed.like' }, body: { repo: DID, collection: COLLECTION, record: {} }, }); expect(response.status).toBe(403); expect((await response.json()).message).toContain(COLLECTION); expect(records.size).toBe(0); }); it('reports a schema failure as InvalidRecord', async () => { const { repo, records } = createRepo({ resolver: { async assertValid() { throw new Error('text must be a string'); }, }, }); const response = await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, record: { text: 1 } }, }); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toBe('InvalidRecord'); expect(body.message).toMatch(/text must be a string/); expect(records.size).toBe(0); }); it('skips validation when the caller asks it to', async () => { let called = false; const { repo } = createRepo({ resolver: { async assertValid() { called = true; return 'valid'; }, }, }); const response = await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, record: { text: 1 }, validate: false, }, }); expect(response.status).toBe(200); expect(called).toBe(false); }); }); describe('getRecord', () => { it('answers with the stored value', async () => { const { repo } = createRepo(); const created = await ( await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'hi' }, }, }) ).json(); const body = await ( await call(repo, `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=one`) ).json(); expect(body.uri).toBe(created.uri); expect(body.cid).toBe(created.cid); expect(body.value.text).toBe('hi'); }); it('is a RecordNotFound for an rkey that is not there', async () => { const { repo } = createRepo(); const response = await call( repo, `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=absent`, ); expect(response.status).toBe(404); expect((await response.json()).error).toBe('RecordNotFound'); }); }); describe('listRecords', () => { it('lists a collection', async () => { const { repo } = createRepo(); for (const rkey of ['a', 'b']) { await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey, record: { text: rkey }, }, }); } const body = await ( await call(repo, `${LIST}?repo=${DID}&collection=${COLLECTION}`) ).json(); expect(body.records).toHaveLength(2); expect(body.records[0].value.text).toBeTruthy(); }); it('lists newest rkey first, oldest first with reverse', async () => { const { repo } = createRepo(); for (const rkey of ['a', 'b']) { await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey, record: { text: rkey }, }, }); } const texts = async (/** @type {string} */ params) => { const body = await ( await call( repo, `${LIST}?repo=${DID}&collection=${COLLECTION}${params}`, ) ).json(); return body.records.map( (/** @type {{value: {text: string}}} */ r) => r.value.text, ); }; expect(await texts('')).toEqual(['b', 'a']); expect(await texts('&reverse=true')).toEqual(['a', 'b']); }); }); describe('putRecord', () => { it('replaces the value and commits an update', async () => { const { repo, commits } = createRepo(); await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'first' }, }, }); const response = await call(repo, PUT, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'second' }, }, }); expect(response.status).toBe(200); const body = await ( await call(repo, `${GET}?repo=${DID}&collection=${COLLECTION}&rkey=one`) ).json(); expect(body.value.text).toBe('second'); expect(commits[1][0].action).toBe('update'); }); }); describe('deleteRecord', () => { it('removes the record and unlinks its blobs', async () => { const { repo, records, unlinked, commits } = createRepo(); const created = await ( await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'a' }, }, }) ).json(); const response = await call(repo, DELETE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'one' }, }); expect(response.status).toBe(200); expect(records.has(created.uri)).toBe(false); expect(unlinked).toContain(created.uri); expect(commits[1][0].action).toBe('delete'); }); it('reaps only the blobs the deleted record held', async () => { // Another orphan can be a blob a client uploaded for a record it is // about to write. Reaping it would leave that record without its blob. const { repo, reaped } = createRepo(); const blob = 'bafkreiheldbytherecordaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'a', log: { $type: 'blob', ref: { $link: blob }, mimeType: 'text/plain' }, }, }, }); await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'two', record: { text: 'b' }, }, }); const before = Date.now(); for (const rkey of ['one', 'two']) { await call(repo, DELETE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey }, }); } expect(reaped).toHaveLength(1); expect(reaped[0].only).toEqual([blob]); expect(reaped[0].olderThan).toBeGreaterThan(before); expect(reaped[0].olderThan).toBeLessThanOrEqual(Date.now() + 1); }); it('deleting a record that is not there commits nothing', async () => { // The tree does not change, so there is nothing to announce. Committing // anyway puts an op on the firehose that the MST diff does not contain — // a delete for a record the repo never had — and clients retry, so one // stray delete became a run of impossible commits. const { repo, commits } = createRepo(); const response = await call(repo, DELETE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'never-existed' }, }); expect(response.status).toBe(200); expect(commits).toHaveLength(0); }); it('deleting the same record twice commits once', async () => { const { repo, commits } = createRepo(); await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'a' }, }, }); const body = { repo: DID, collection: COLLECTION, rkey: 'one' }; await call(repo, DELETE, { auth: FULL, body }); const second = await call(repo, DELETE, { auth: FULL, body }); expect(second.status).toBe(200); // One for the create, one for the delete that did something. expect(commits).toHaveLength(2); }); }); describe('applyWrites', () => { it('a delete of a record that is not there contributes no op', async () => { const { repo, commits } = createRepo(); const response = await call(repo, APPLY, { auth: FULL, body: { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#delete', collection: COLLECTION, rkey: 'never-existed', }, ], }, }); expect(response.status).toBe(200); // The result still stands, one per write, but nothing was committed. const body = await response.json(); expect(body.results).toHaveLength(1); expect(body.results[0].$type).toBe( 'com.atproto.repo.applyWrites#deleteResult', ); expect(commits).toHaveLength(0); }); it('lands every operation against one commit', async () => { const { repo, records, commits } = createRepo(); const response = await call(repo, APPLY, { auth: FULL, body: { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'a', value: { text: 'a' }, }, { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'b', value: { text: 'b' }, }, ], }, }); expect(response.status).toBe(200); expect(records.size).toBe(2); // One commit carrying both operations, not one commit each. expect(commits).toHaveLength(1); expect(commits[0]).toHaveLength(2); }); it('refuses a create at a key the repo already holds, writing nothing', async () => { const { repo, records, commits } = createRepo(); const first = await ( await call(repo, CREATE, { auth: FULL, body: { repo: DID, collection: COLLECTION, rkey: 'taken', record: { text: 'first' }, }, }) ).json(); const response = await call(repo, APPLY, { auth: FULL, body: { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'other', value: { text: 'other' }, }, { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'taken', value: { text: 'second' }, }, ], }, }); expect(response.status).toBe(400); expect((await response.json()).message).toBe( `Record already exists: ${first.uri}`, ); expect(records.size).toBe(1); expect(records.get(first.uri)?.cid).toBe(first.cid); expect(commits).toHaveLength(1); }); it('refuses two creates of one key in a batch', async () => { const { repo, records, commits } = createRepo(); const create = { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'twice', value: { text: 'a' }, }; const response = await call(repo, APPLY, { auth: FULL, body: { repo: DID, writes: [create, { ...create, value: { text: 'b' } }], }, }); expect(response.status).toBe(400); expect(records.size).toBe(0); expect(commits).toHaveLength(0); }); it('refuses a read-only server', async () => { const { repo } = createRepo({ readOnly: true }); const response = await call(repo, APPLY, { auth: FULL, body: { repo: DID, writes: [] }, }); expect(response.status).toBe(401); }); it('needs a session', async () => { const { repo } = createRepo(); const response = await call(repo, APPLY, { body: { repo: DID, writes: [] }, }); 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('refuses a batch operation the scope does not cover', async () => { const { repo, records, commits } = createRepo(); const response = await call(repo, APPLY, { auth: { did: DID, scope: 'repo:app.bsky.feed.like' }, body: { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'a', value: {}, }, ], }, }); expect(response.status).toBe(403); expect((await response.json()).message).toContain(COLLECTION); expect(records.size).toBe(0); expect(commits).toHaveLength(0); }); // The whole batch is checked before anything is written, so a partly covered // batch changes nothing rather than landing its allowed half. it('writes nothing when one operation in a batch is not covered', async () => { const { repo, records, commits } = createRepo(); const response = await call(repo, APPLY, { auth: { did: DID, scope: `repo:${COLLECTION}?action=create` }, body: { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'allowed', value: {}, }, { $type: 'com.atproto.repo.applyWrites#delete', collection: COLLECTION, rkey: 'allowed', }, ], }, }); expect(response.status).toBe(403); expect((await response.json()).message).toMatch(/delete/); expect(records.size).toBe(0); expect(commits).toHaveLength(0); }); 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); }); // putRecord is an upsert, so a scope allowing only one of create or update is // not enough to overwrite a record. it('refuses a collection the scope does not cover on put', async () => { const { repo, records } = createRepo(); const response = await call(repo, PUT, { auth: { did: DID, scope: 'repo:app.bsky.feed.like' }, body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} }, }); expect(response.status).toBe(403); expect((await response.json()).message).toContain(COLLECTION); expect(records.size).toBe(0); }); it('refuses a put scope that allows create but not update', async () => { const { repo, records } = createRepo(); const response = await call(repo, PUT, { auth: { did: DID, scope: `repo:${COLLECTION}?action=create` }, body: { repo: DID, collection: COLLECTION, rkey: 'a', record: {} }, }); expect(response.status).toBe(403); expect((await response.json()).message).toMatch(/update/); expect(records.size).toBe(0); }); 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); }); }); // An app-password session names no granular scopes, and must still be able to // write. It is bounded elsewhere: it cannot mint app passwords or read the email. describe('app-password sessions', () => { const APP_PASS = { did: DID, scope: 'com.atproto.appPass' }; it('creates, overwrites and deletes a record', async () => { const { repo, records } = createRepo(); const created = await ( await call(repo, CREATE, { auth: APP_PASS, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'a' }, }, }) ).json(); expect(records.has(created.uri)).toBe(true); const put = await call(repo, PUT, { auth: APP_PASS, body: { repo: DID, collection: COLLECTION, rkey: 'one', record: { text: 'b' }, }, }); expect(put.status).toBe(200); const removed = await call(repo, DELETE, { auth: APP_PASS, body: { repo: DID, collection: COLLECTION, rkey: 'one' }, }); expect(removed.status).toBe(200); expect(records.has(created.uri)).toBe(false); }); it('applies a batch', async () => { const { repo, records } = createRepo(); const response = await call(repo, APPLY, { auth: APP_PASS, body: { repo: DID, writes: [ { $type: 'com.atproto.repo.applyWrites#create', collection: COLLECTION, rkey: 'a', value: { text: 'a' }, }, ], }, }); expect(response.status).toBe(200); expect(records.size).toBe(1); }); }); 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/); }); });