// clearRecords, and the collections no caller may write directly: the policy // and member records the authority's PDS publishes for a space. import { sign } from '@pdsjs/core/crypto'; import { beforeEach, describe, expect, it } from 'vitest'; import { createMemoryBlobContext, createMemorySpaceStorage, } from '../src/memory-storage.js'; import { createSpaceRoutes } from '../src/routes.js'; import { makeSpaceRow } from '../src/space-row.js'; import { applyWrites } from '../src/writer.js'; const AUTHORITY = 'did:plc:aaaa'; const WRITER = 'did:plc:writer'; const TYPE = 'com.example.forum'; const SPACE = `at://${AUTHORITY}/space/${TYPE}/general`; const COL = 'com.example.post'; const OTHER_COL = 'com.example.like'; /** @type {any} */ let store; beforeEach(() => { store = createMemorySpaceStorage(); }); /** @param {string} did */ async function routesAs(did) { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); return createSpaceRoutes({ spaceStorage: store, ...createMemoryBlobContext(), getDid: async () => did, getKemKey: async () => null, getSigner: async () => ({ /** @param {Uint8Array} bytes */ sign: (bytes) => sign(keys.privateKey, bytes), }), resolveDid: async () => null, verifier: { verify: async () => true }, }); } /** * @param {any} routes * @param {string} path * @param {any} body * @param {{did: string, scope: string}} auth * @returns {Promise} */ function post(routes, path, body, auth) { const url = new URL(`https://pds.example${path}`); return routes[path].handler( new Request(url, { method: 'POST', body: JSON.stringify(body) }), url, auth, ); } /** @param {string} did @param {string[]} collections */ function scope(did, collections) { const params = collections.map((c) => `collection=${c}`).join('&'); return { did, scope: `space:${TYPE}?authority=${AUTHORITY}&${params}` }; } /** @param {string} owner @param {Array<[string, string]>} records */ async function seed(owner, records) { await applyWrites(store, { space: SPACE, owner, writes: records.map(([collection, rkey]) => ({ action: 'create', collection, rkey, record: { text: rkey }, })), }); } const listed = async () => (await store.listAllSpaceRecords(SPACE)).map( (/** @type {any} */ r) => `${r.collection}/${r.rkey}`, ); describe('clearRecords', () => { it('deletes every record in the caller repo', async () => { const routes = await routesAs(WRITER); await seed(WRITER, [ [COL, 'a'], [COL, 'b'], [OTHER_COL, 'c'], ]); const before = await store.getSpaceRepo(SPACE); const res = await post( routes, '/xrpc/com.atproto.space.clearRecords', { space: SPACE, repo: WRITER }, scope(WRITER, [COL, OTHER_COL]), ); expect(res.status).toBe(200); expect(await listed()).toEqual([]); expect((await store.getSpaceRepo(SPACE)).rev).not.toBe(before.rev); }); it('needs a delete grant for every collection it holds', async () => { const routes = await routesAs(WRITER); await seed(WRITER, [ [COL, 'a'], [OTHER_COL, 'c'], ]); const res = await post( routes, '/xrpc/com.atproto.space.clearRecords', { space: SPACE, repo: WRITER }, scope(WRITER, [COL]), ); expect(res.status).toBe(403); expect(await listed()).toHaveLength(2); }); it('clears only the caller repo', async () => { const routes = await routesAs(WRITER); const res = await post( routes, '/xrpc/com.atproto.space.clearRecords', { space: SPACE, repo: 'did:plc:someone' }, scope(WRITER, [COL]), ); expect(res.status).toBe(403); }); it('is a no-op on an empty repo', async () => { const routes = await routesAs(WRITER); const res = await post( routes, '/xrpc/com.atproto.space.clearRecords', { space: SPACE, repo: WRITER }, scope(WRITER, [COL]), ); expect(res.status).toBe(200); expect(await store.getSpaceRepo(SPACE)).toBeNull(); }); it('keeps the published policy and members in the authority repo', async () => { const routes = await routesAs(AUTHORITY); await store.putSpace( makeSpaceRow(SPACE, { isOwner: true, policy: { $type: 'com.atproto.memberspace.policy', publishPolicy: true, publishMembers: true, }, }), ); await seed(AUTHORITY, [ [COL, 'a'], ['com.atproto.space.policy', 'self'], ['com.atproto.memberspace.member', WRITER], ]); const res = await post( routes, '/xrpc/com.atproto.space.clearRecords', { space: SPACE, repo: AUTHORITY }, scope(AUTHORITY, [COL]), ); expect(res.status).toBe(200); expect(await listed()).toEqual([ `com.atproto.memberspace.member/${WRITER}`, 'com.atproto.space.policy/self', ]); }); }); describe('the published collections', () => { for (const collection of [ 'com.atproto.space.policy', 'com.atproto.memberspace.member', ]) { it(`refuses a direct write to ${collection}`, async () => { const routes = await routesAs(WRITER); const auth = scope(WRITER, [collection]); const single = await post( routes, '/xrpc/com.atproto.space.putRecord', { space: SPACE, repo: WRITER, collection, rkey: 'self', record: {} }, auth, ); expect(single.status).toBe(400); const batch = await post( routes, '/xrpc/com.atproto.space.applyWrites', { space: SPACE, repo: WRITER, writes: [{ action: 'create', collection, rkey: 'self', value: {} }], }, auth, ); expect(batch.status).toBe(400); expect(await listed()).toEqual([]); }); } });