// Blobs behind space records: the two read endpoints, and the link table entry // that keeps orphan cleanup from deleting bytes a space record still points at. 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}`, }; // Real-looking CIDv1 raw strings; nothing here hashes them. const CID_A = 'bafyreicl6ujc6ncfktctxxroxognfn7d2fqavvrryoc2lv6m4i6hpbkfti'; const CID_B = 'bafyreig3yg2msah74sgvow25uxddqbabex3f3mh6hysess3w5kmgiv6zqy'; /** @param {string} cid */ function blobRef(cid) { return { $type: 'blob', ref: { $link: cid }, mimeType: 'image/png', size: 3 }; } /** @param {string} cid */ function postWith(cid) { return { $type: COL, text: 'hi', image: blobRef(cid) }; } /** @type {any} */ let routes; /** @type {any} */ let store; /** @type {any} */ let blobs; beforeEach(async () => { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); store = createMemorySpaceStorage(); blobs = createMemoryBlobContext(); routes = createSpaceRoutes({ spaceStorage: store, ...blobs, 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 {string} path @param {any} body */ function post(path, body) { 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} path @param {Record} params */ function get(path, params = {}) { const url = new URL(`https://pds.example${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); return routes[path].handler(new Request(url), url, AUTH); } /** @param {string} rkey @param {string} cid */ function writePost(rkey, cid) { return post('/xrpc/com.atproto.space.createRecord', { space: SPACE, repo: DID, collection: COL, rkey, record: postWith(cid), }); } const uriFor = (/** @type {string} */ rkey) => `${SPACE}/${DID}/${COL}/${rkey}`; describe('blob links on the space write path', () => { it('links a blob the written record references', async () => { await writePost('a', CID_A); expect([...blobs.links.get(uriFor('a'))]).toEqual([CID_A]); }); it('replaces the links when a record is rewritten', async () => { await writePost('a', CID_A); await post('/xrpc/com.atproto.space.putRecord', { space: SPACE, repo: DID, collection: COL, rkey: 'a', record: postWith(CID_B), }); expect([...blobs.links.get(uriFor('a'))]).toEqual([CID_B]); }); it('drops the links when a record is deleted', async () => { await writePost('a', CID_A); await post('/xrpc/com.atproto.space.deleteRecord', { space: SPACE, repo: DID, collection: COL, rkey: 'a', }); expect(blobs.links.has(uriFor('a'))).toBe(false); }); it('links every blob an applyWrites batch leaves behind', async () => { await post('/xrpc/com.atproto.space.applyWrites', { space: SPACE, repo: DID, writes: [ { $type: 'com.atproto.space.applyWrites#create', collection: COL, rkey: 'a', value: postWith(CID_A), }, { $type: 'com.atproto.space.applyWrites#create', collection: COL, rkey: 'b', value: postWith(CID_B), }, ], }); expect([...blobs.links.get(uriFor('a'))]).toEqual([CID_A]); expect([...blobs.links.get(uriFor('b'))]).toEqual([CID_B]); }); it('links a blob under the rkey the server minted', async () => { const { uri } = await ( await post('/xrpc/com.atproto.space.createRecord', { space: SPACE, repo: DID, collection: COL, record: postWith(CID_A), }) ).json(); expect([...blobs.links.get(uri)]).toEqual([CID_A]); }); }); describe('listBlobs', () => { it('lists the blobs the space references', async () => { await writePost('a', CID_A); await writePost('b', CID_B); const { cids } = await ( await get('/xrpc/com.atproto.space.listBlobs', { space: SPACE }) ).json(); expect(cids.sort()).toEqual([CID_A, CID_B].sort()); }); it('filters by the rev that first referenced a blob', async () => { await writePost('a', CID_A); const { rev } = /** @type {any} */ (await store.getSpaceRepo(SPACE)); await writePost('b', CID_B); const { cids } = await ( await get('/xrpc/com.atproto.space.listBlobs', { space: SPACE, since: rev, }) ).json(); expect(cids).toEqual([CID_B]); }); it('answers empty for a space with no records', async () => { const res = await get('/xrpc/com.atproto.space.listBlobs', { space: SPACE, }); expect(await res.json()).toEqual({ cids: [] }); }); }); describe('getBlob', () => { it('serves a blob a record in the space references', async () => { blobs.put(CID_A, new Uint8Array([1, 2, 3]), 'image/png'); await writePost('a', CID_A); const res = await get('/xrpc/com.atproto.space.getBlob', { space: SPACE, cid: CID_A, }); expect(res.status).toBe(200); expect(res.headers.get('Content-Type')).toBe('image/png'); expect(new Uint8Array(await res.arrayBuffer())).toEqual( new Uint8Array([1, 2, 3]), ); }); it('refuses a blob no record in the space references', async () => { // Held by the account, but behind a public record rather than this space. blobs.put(CID_B, new Uint8Array([9]), 'image/png'); await writePost('a', CID_A); const res = await get('/xrpc/com.atproto.space.getBlob', { space: SPACE, cid: CID_B, }); expect(res.status).toBe(404); expect((await res.json()).error).toBe('BlobNotFound'); }); it('404s a referenced blob whose bytes are gone', async () => { await writePost('a', CID_A); const res = await get('/xrpc/com.atproto.space.getBlob', { space: SPACE, cid: CID_A, }); expect(res.status).toBe(404); }); it('requires a cid', async () => { const res = await get('/xrpc/com.atproto.space.getBlob', { space: SPACE }); expect(res.status).toBe(400); }); });