// applyWrites discriminates its writes two ways. The proposal made them a // closed union tagged by `$type` after clients had shipped against an `action` // string, so both have to be read. 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'; const AUTHORITY = 'did:plc:aaaa'; const DID = 'did:plc:writer'; const SPACE = `at://${AUTHORITY}/space/com.example.forum/general`; const COL = 'com.example.post'; const AUTH = { did: DID, scope: `space:com.example.forum?authority=${AUTHORITY}&collection=${COL}`, }; const PATH = '/xrpc/com.atproto.space.applyWrites'; /** @type {any} */ let routes; /** @type {any} */ let store; beforeEach(async () => { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); store = createMemorySpaceStorage(); routes = 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[]} writes */ function applyWrites(writes) { const url = new URL(`https://pds.example${PATH}`); return routes[PATH].handler( new Request(url, { method: 'POST', body: JSON.stringify({ space: SPACE, repo: DID, writes }), }), url, AUTH, ); } describe('applyWrites write shapes', () => { it('reads a write tagged by $type', async () => { const res = await applyWrites([ { $type: 'com.atproto.space.applyWrites#create', collection: COL, rkey: 'a', value: { text: 'hi' }, }, ]); expect(res.status).toBe(200); expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy(); }); it('reads a write named by an action string', async () => { const res = await applyWrites([ { action: 'create', collection: COL, rkey: 'a', value: { text: 'hi' } }, ]); expect(res.status).toBe(200); const body = await res.json(); // The result is a union either way; only the input spelling differs. expect(body.results[0].$type).toBe( 'com.atproto.space.applyWrites#createResult', ); expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy(); }); it('mints a key for a create that leaves rkey empty', async () => { const res = await applyWrites([ { action: 'create', collection: COL, rkey: '', value: { text: 'hi' } }, ]); const body = await res.json(); expect(body.results[0].uri.split('/').pop()).toHaveLength(13); }); it('updates and deletes by action string', async () => { await applyWrites([ { action: 'create', collection: COL, rkey: 'a', value: { v: 1 } }, ]); await applyWrites([ { action: 'update', collection: COL, rkey: 'a', value: { v: 2 } }, ]); expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeTruthy(); const res = await applyWrites([ { action: 'delete', collection: COL, rkey: 'a' }, ]); expect(res.status).toBe(200); expect(await store.getSpaceRecord(SPACE, COL, 'a')).toBeNull(); }); it('names what it could not place when neither spelling is present', async () => { const res = await applyWrites([{ collection: COL, rkey: 'a', value: {} }]); expect(res.status).toBe(400); expect((await res.json()).message).toContain('(none)'); }); it('refuses an action it does not implement', async () => { const res = await applyWrites([ { action: 'upsert', collection: COL, rkey: 'a', value: {} }, ]); expect(res.status).toBe(400); expect((await res.json()).message).toContain('upsert'); }); }); // putRecord carries an optional swapRecord, a pds.js extension: a CID names // the version the record must be at, null requires that it does not exist. describe('putRecord swapRecord', () => { const PUT_PATH = '/xrpc/com.atproto.space.putRecord'; /** @param {Record} body */ function putRecord(body) { const url = new URL(`https://pds.example${PUT_PATH}`); return routes[PUT_PATH].handler( new Request(url, { method: 'POST', body: JSON.stringify({ space: SPACE, repo: DID, ...body }), }), url, AUTH, ); } it('accepts a matching CID and rejects a stale one', async () => { const first = await putRecord({ collection: COL, rkey: 'a', record: { v: 1 }, swapRecord: null, }); expect(first.status).toBe(200); const { cid } = await first.json(); const second = await putRecord({ collection: COL, rkey: 'a', record: { v: 2 }, swapRecord: cid, }); expect(second.status).toBe(200); const stale = await putRecord({ collection: COL, rkey: 'a', record: { v: 3 }, swapRecord: cid, }); expect(stale.status).toBe(400); expect((await stale.json()).error).toBe('InvalidSwap'); }); it('rejects swapRecord null when the record exists', async () => { await putRecord({ collection: COL, rkey: 'a', record: { v: 1 } }); const res = await putRecord({ collection: COL, rkey: 'a', record: { v: 2 }, swapRecord: null, }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('InvalidSwap'); }); it('rejects a swapRecord that is not a CID string or null', async () => { const res = await putRecord({ collection: COL, rkey: 'a', record: { v: 1 }, swapRecord: 7, }); expect(res.status).toBe(400); expect((await res.json()).error).toBe('InvalidRequest'); }); it('writes without a check when swapRecord is absent', async () => { await putRecord({ collection: COL, rkey: 'a', record: { v: 1 } }); const res = await putRecord({ collection: COL, rkey: 'a', record: { v: 2 }, }); expect(res.status).toBe(200); }); });