// The storage views an extension receives: record calls check the // collection they name against what the extension declared; everything // else reaches the server's own port unchanged. import { describe, expect, it, vi } from 'vitest'; import { anonymousRequest, collectionOfUri, createCollectionScope, scopeActorStorage, scopeCommits, scopeSpaceBrowser, scopeSpaceStorage, } from '../packages/core/src/extension-scope.js'; const DID = 'did:plc:scopetest'; const SPACE = `at://${DID}/space/com.example.team/main`; /** @param {string[]|undefined} declared */ const scopeOf = (declared) => createCollectionScope( () => 'demo', () => declared, ); describe('collectionOfUri', () => { it('reads the segment before the record key, public or in a space', () => { expect(collectionOfUri(`at://${DID}/app.example.post/3k`)).toBe( 'app.example.post', ); expect(collectionOfUri(`${SPACE}/${DID}/app.example.post/3k`)).toBe( 'app.example.post', ); expect(collectionOfUri('at://nothing')).toBe(''); }); }); describe('createCollectionScope', () => { it('allows the declared collections and nothing else', () => { const scope = scopeOf(['app.example.post']); expect(scope.allows('app.example.post')).toBe(true); expect(scope.allows('app.example.like')).toBe(false); expect(() => scope.assert('app.example.like')).toThrow( 'Extension demo may not touch app.example.like: it declares app.example.post', ); expect(() => scope.assertAll()).toThrow(/every collection/); }); it('allows everything for a wildcard and nothing when undeclared', () => { expect(scopeOf(['*']).allows('anything.at.all')).toBe(true); expect(() => scopeOf(['*']).assertAll()).not.toThrow(); expect(() => scopeOf(undefined).assert('app.example.post')).toThrow( /declares no collections/, ); }); it('reads the declaration on every check', () => { /** @type {string[]|undefined} */ let declared; const scope = createCollectionScope( () => 'late', () => declared, ); expect(scope.allows('a.b')).toBe(false); declared = ['a.b']; expect(scope.allows('a.b')).toBe(true); }); }); describe('scopeActorStorage', () => { function fakeStorage() { return { getRecord: vi.fn(async () => null), listRecords: vi.fn(async () => ({ records: [], cursor: null })), listAllRecords: vi.fn(async () => []), putRecord: vi.fn(async () => {}), deleteRecord: vi.fn(async () => {}), linkBlobToRecord: vi.fn(async () => {}), unlinkBlobsFromRecord: vi.fn(async () => {}), putBlock: vi.fn(async () => {}), // Bound to the storage, so a port written as a class still works. getHandle: vi.fn( /** @this {unknown} */ async function () { return this === storage ? 'bound.handle' : 'unbound'; }, ), someField: 42, }; } const storage = fakeStorage(); const scoped = scopeActorStorage( /** @type {any} */ (storage), scopeOf(['app.example.post']), ); it('passes record calls in the declared collection through', async () => { await scoped.getRecord(`at://${DID}/app.example.post/1`); await scoped.listRecords('app.example.post', null, 10); await scoped.putRecord( `at://${DID}/app.example.post/1`, 'bafy', 'app.example.post', '1', new Uint8Array(), ); await scoped.deleteRecord(`at://${DID}/app.example.post/1`); await scoped.linkBlobToRecord('bafyblob', `at://${DID}/app.example.post/1`); await scoped.unlinkBlobsFromRecord(`at://${DID}/app.example.post/1`); expect(storage.getRecord).toHaveBeenCalledTimes(1); expect(storage.listRecords).toHaveBeenCalledTimes(1); expect(storage.putRecord).toHaveBeenCalledTimes(1); expect(storage.deleteRecord).toHaveBeenCalledTimes(1); expect(storage.linkBlobToRecord).toHaveBeenCalledTimes(1); expect(storage.unlinkBlobsFromRecord).toHaveBeenCalledTimes(1); }); it('refuses record calls outside it before reaching the port', async () => { const other = `at://${DID}/app.example.like/1`; await expect(scoped.getRecord(other)).rejects.toThrow(/may not touch/); await expect( scoped.listRecords('app.example.like', null, 10), ).rejects.toThrow(/may not touch/); await expect( scoped.putRecord( other, 'bafy', 'app.example.like', '1', new Uint8Array(), ), ).rejects.toThrow(/may not touch/); await expect(scoped.deleteRecord(other)).rejects.toThrow(/may not touch/); await expect(scoped.linkBlobToRecord('bafyblob', other)).rejects.toThrow( /may not touch/, ); await expect(scoped.unlinkBlobsFromRecord(other)).rejects.toThrow( /may not touch/, ); await expect(scoped.listAllRecords()).rejects.toThrow(/every collection/); expect(storage.getRecord).toHaveBeenCalledTimes(1); expect(storage.listAllRecords).not.toHaveBeenCalled(); }); it('passes everything that names no collection through, bound', async () => { await scoped.putBlock('bafy', new Uint8Array()); expect(storage.putBlock).toHaveBeenCalledTimes(1); expect(await scoped.getHandle()).toBe('bound.handle'); expect(/** @type {any} */ (scoped).someField).toBe(42); }); }); describe('scopeSpaceStorage', () => { const storage = { getSpaceRecord: vi.fn(async () => null), getSpaceRecordCid: vi.fn(async () => null), listSpaceRecords: vi.fn(async () => ({ records: [], cursor: null })), listAllSpaceRecords: vi.fn(async () => []), commitSpaceWrite: vi.fn(async () => {}), getSpace: vi.fn(async () => null), }; const scoped = scopeSpaceStorage( /** @type {any} */ (storage), scopeOf(['app.example.post']), ); it('checks the collection a record call or commit names', async () => { await scoped.getSpaceRecord(SPACE, 'app.example.post', '1'); await scoped.listSpaceRecords(SPACE, { collection: 'app.example.post', limit: 5, }); await scoped.commitSpaceWrite(SPACE, { writes: [ /** @type {any} */ ({ action: 'create', collection: 'app.example.post', rkey: '1', }), ], setHash: new Uint8Array(), rev: '3l', indexedAt: 'now', }); await scoped.getSpace(SPACE); expect(storage.getSpaceRecord).toHaveBeenCalledTimes(1); expect(storage.commitSpaceWrite).toHaveBeenCalledTimes(1); expect(storage.getSpace).toHaveBeenCalledTimes(1); await expect( scoped.getSpaceRecordCid(SPACE, 'app.example.like', '1'), ).rejects.toThrow(/may not touch/); await expect(scoped.listSpaceRecords(SPACE, { limit: 5 })).rejects.toThrow( /every collection/, ); await expect(scoped.listAllSpaceRecords(SPACE)).rejects.toThrow( /every collection/, ); await expect( scoped.commitSpaceWrite(SPACE, { writes: [ /** @type {any} */ ({ action: 'create', collection: 'app.example.post', rkey: '1', }), /** @type {any} */ ({ action: 'create', collection: 'app.example.like', rkey: '2', }), ], setHash: new Uint8Array(), rev: '3l', indexedAt: 'now', }), ).rejects.toThrow(/may not touch app.example.like/); expect(storage.commitSpaceWrite).toHaveBeenCalledTimes(1); }); }); describe('scopeCommits', () => { it('refuses a commit with any op outside the scope, before it starts', async () => { const createCommit = vi.fn(async () => ({ cid: 'bafy', rev: '3l' })); const commit = scopeCommits(createCommit, scopeOf(['app.example.post'])); await expect( commit(DID, [ { action: 'create', uri: `at://${DID}/app.example.post/1` }, ]), ).resolves.toEqual({ cid: 'bafy', rev: '3l' }); await expect( commit(DID, [ { action: 'create', uri: `at://${DID}/app.example.post/2` }, { action: 'delete', uri: `at://${DID}/app.example.like/1` }, ]), ).rejects.toThrow(/may not touch app.example.like/); expect(createCommit).toHaveBeenCalledTimes(1); }); }); describe('anonymousRequest', () => { it('removes the credential headers and keeps the rest', async () => { const request = new Request('https://pds.internal/xrpc/x', { method: 'POST', headers: { authorization: 'Bearer secret', cookie: 'pdsjs_account=secret', dpop: 'proof', 'content-type': 'application/json', }, body: '{"a":1}', }); const anonymous = anonymousRequest(request); expect(anonymous.headers.has('authorization')).toBe(false); expect(anonymous.headers.has('cookie')).toBe(false); expect(anonymous.headers.has('dpop')).toBe(false); expect(anonymous.headers.get('content-type')).toBe('application/json'); expect(anonymous.method).toBe('POST'); expect(await anonymous.text()).toBe('{"a":1}'); }); it('returns a request that carries none unchanged', () => { const request = new Request('https://pds.internal/xrpc/x'); expect(anonymousRequest(request)).toBe(request); }); }); describe('scopeSpaceBrowser', () => { const browser = { listSpaces: vi.fn(async () => []), countRecords: vi.fn(async () => []), countMembers: vi.fn(async () => 0), listMembers: vi.fn(async () => []), listRecords: vi.fn(async () => ({ records: [], truncated: false })), listAllRecords: vi.fn(async () => []), }; const scoped = scopeSpaceBrowser(browser, scopeOf(['app.example.post'])); it('checks a collection listing and needs every collection for the whole view', async () => { await scoped.listSpaces(); await scoped.listMembers(SPACE); await scoped.listRecords(SPACE, 'app.example.post', 10); expect(browser.listRecords).toHaveBeenCalledTimes(1); await expect( scoped.listRecords(SPACE, 'app.example.like', 10), ).rejects.toThrow(/may not touch/); await expect(scoped.listAllRecords()).rejects.toThrow(/every collection/); expect(browser.listAllRecords).not.toHaveBeenCalled(); }); });