// Which server each call is addressed to, and with which credential. This is // the part of the client that is easy to get wrong and invisible when it is: // a read sent to the wrong host answers RepoNotFound rather than nothing. import { beforeEach, describe, expect, it, vi } from 'vitest'; const authedFetch = vi.fn(); const credentialFetch = vi.fn(); const spaceCredential = vi.fn(async () => 'a-credential'); const repoHost = vi.fn( async (did) => `https://${did.split(':').pop()}.example`, ); vi.mock('#/lib/oauth.js', () => ({ authedFetch })); const SECRET = new Uint8Array(32).fill(7); const wantMac = vi.fn(async () => ({ headers: { 'atproto-space-want-mac': 'a-key-share' }, sharedSecret: SECRET, })); vi.mock('#/lib/credential.js', () => ({ credentialFetch, repoHost, spaceCredential, wantMac, })); const { cborEncodeDagCbor, cidToString, createCid } = await import( '@pdsjs/core/repo' ); const { COMMIT_MAC_TYPE, computeCommitMac, computeRecordMac, formatMacHeader, RECORD_MAC_TYPE, } = await import('@pdsjs/spaces/mac'); const { XrpcError, createRecord, createSpace, deleteRecord, getLatestCommit, getRecord, getSpace, listRecords, listRepos, } = await import('#/lib/spaces.js'); const ALICE = 'did:plc:alice'; const BOB = 'did:plc:bob'; const SPACE = `at://${ALICE}/space/com.example.forum/general`; /** @type {any} */ const session = { did: ALICE, pds: 'https://alice.example', accessToken: 't', }; /** @param {unknown} body @param {number} [status] */ function answers(body, status = 200) { return Response.json(body, { status }); } beforeEach(() => { vi.clearAllMocks(); authedFetch.mockResolvedValue(answers({ records: [] })); credentialFetch.mockResolvedValue(answers({ records: [] })); }); describe('reading a repo', () => { it("sends the signed-in account's own repo to its own PDS", async () => { await listRecords(session, { space: SPACE }); expect(credentialFetch).not.toHaveBeenCalled(); const url = new URL(authedFetch.mock.calls[0][1]); expect(url.origin).toBe('https://alice.example'); expect(url.pathname).toBe('/xrpc/com.atproto.space.listRecords'); expect(url.searchParams.get('space')).toBe(SPACE); // Naming the repo is what a host reads as "somebody else's", so the call // for one's own leaves it out. expect(url.searchParams.get('repo')).toBeNull(); }); it("sends another member's repo to that member's host, with a credential", async () => { await listRecords(session, { space: SPACE, repo: BOB }); expect(authedFetch).not.toHaveBeenCalled(); expect(spaceCredential).toHaveBeenCalledWith(session, SPACE); const [credential, url] = credentialFetch.mock.calls[0]; expect(credential).toBe('a-credential'); expect(new URL(url).origin).toBe('https://bob.example'); expect(new URL(url).searchParams.get('repo')).toBe(BOB); }); it("checks another member's record against the MAC it comes with", async () => { const value = { $type: 'com.example.post', text: 'hi' }; const cid = cidToString(await createCid(cborEncodeDagCbor(value))); const uri = `${SPACE}/${BOB}/com.example.post/one`; const mac = formatMacHeader( RECORD_MAC_TYPE, await computeRecordMac(SECRET, uri, cid), ); credentialFetch.mockResolvedValue( Response.json( { uri, cid, value }, { headers: { 'atproto-space-mac': mac } }, ), ); const record = await getRecord( session, { space: SPACE, repo: BOB }, 'com.example.post', 'one', ); expect(record.value).toEqual(value); expect(wantMac).toHaveBeenCalledWith(BOB, RECORD_MAC_TYPE); expect(credentialFetch.mock.calls[0][2]).toEqual({ headers: { 'atproto-space-want-mac': 'a-key-share' }, }); credentialFetch.mockResolvedValue( Response.json( { uri, cid, value: { ...value, text: 'changed' } }, { headers: { 'atproto-space-mac': mac } }, ), ); await expect( getRecord( session, { space: SPACE, repo: BOB }, 'com.example.post', 'one', ), ).rejects.toMatchObject({ name: 'BadSpaceMac' }); }); it("checks another member's commit against its MAC", async () => { const commit = { ver: 1, repo: BOB, space: SPACE, rev: '3l', data: new Uint8Array(32).fill(1), }; const mac = formatMacHeader( COMMIT_MAC_TYPE, await computeCommitMac(SECRET, commit), ); const json = { ...commit, data: { $bytes: btoa('\x01'.repeat(32)) } }; credentialFetch.mockResolvedValue( Response.json( { commit: json }, { headers: { 'atproto-space-mac': mac } }, ), ); expect(await getLatestCommit(session, { space: SPACE, repo: BOB })).toEqual( json, ); credentialFetch.mockResolvedValue(Response.json({ commit: json })); await expect( getLatestCommit(session, { space: SPACE, repo: BOB }), ).rejects.toMatchObject({ name: 'BadSpaceMac' }); // A repo with no commit has nothing to MAC. credentialFetch.mockResolvedValue(Response.json({})); expect(await getLatestCommit(session, { space: SPACE, repo: BOB })).toBe( null, ); }); it('addresses a repo named as the signed-in account as its own', async () => { await listRecords(session, { space: SPACE, repo: ALICE }); expect(credentialFetch).not.toHaveBeenCalled(); expect(authedFetch).toHaveBeenCalledOnce(); }); it('leaves an unset option out of the query rather than sending it empty', async () => { await listRecords(session, { space: SPACE }, { limit: 10 }); const url = new URL(authedFetch.mock.calls[0][1]); expect(url.searchParams.get('limit')).toBe('10'); expect(url.searchParams.has('collection')).toBe(false); expect(url.searchParams.has('reverse')).toBe(false); }); }); describe('the authority calls', () => { // Hosts disagree about whether listRepos needs authentication, so it is asked // the way every other authority call is: a credential for somebody else's. it("presents a credential to another account's authority", async () => { authedFetch.mockResolvedValue(answers({ repos: [] })); credentialFetch.mockResolvedValue(answers({ repos: [] })); await listRepos(session, `at://${BOB}/space/com.example.forum/general`); expect(repoHost).toHaveBeenCalledWith(BOB); expect(authedFetch).not.toHaveBeenCalled(); const [credential, url] = credentialFetch.mock.calls[0]; expect(credential).toBe('a-credential'); expect(new URL(url).origin).toBe('https://bob.example'); }); // The reference admits no session here, not even the authority's own, so a // space this account hosts is asked for the same way as anybody else's. it('presents a credential even for its own authority', async () => { credentialFetch.mockResolvedValue(answers({ repos: [] })); await listRepos(session, SPACE); expect(authedFetch).not.toHaveBeenCalled(); expect(new URL(credentialFetch.mock.calls[0][1]).pathname).toBe( '/xrpc/com.atproto.space.listRepos', ); }); // A host that answered is answering, whatever it said. Retrying anonymously // would turn its refusal into a second refusal and report the wrong one. it('does not retry anonymously when the host refused the credential', async () => { credentialFetch.mockResolvedValue( answers({ error: 'SpaceDeleted', message: 'gone' }, 400), ); const anonymous = vi.fn(); vi.stubGlobal('fetch', anonymous); const failure = await listRepos(session, SPACE).catch((e) => e); vi.unstubAllGlobals(); expect(failure.name).toBe('SpaceDeleted'); expect(anonymous).not.toHaveBeenCalled(); }); // A space this account has never been admitted to yields no credential. A // host that serves the method openly still answers, so the anonymous read is // worth making rather than reporting the space as unreadable. it('falls back to an anonymous read when no credential can be had', async () => { spaceCredential.mockRejectedValueOnce(new Error('UserNotAuthorized')); const anonymous = vi.fn(async () => answers({ repos: [] })); vi.stubGlobal('fetch', anonymous); await listRepos(session, `at://${BOB}/space/com.example.forum/general`); vi.unstubAllGlobals(); expect(credentialFetch).not.toHaveBeenCalled(); expect(new URL(anonymous.mock.calls[0][0]).origin).toBe( 'https://bob.example', ); }); it('asks the authority for its policy with the owner session', async () => { authedFetch.mockResolvedValueOnce(answers({ uri: SPACE })); await expect(getSpace(session, SPACE)).resolves.toMatchObject({ value: { uri: SPACE }, source: { method: 'com.atproto.space.getSpace', auth: 'session' }, }); expect(new URL(authedFetch.mock.calls[0][1]).pathname).toBe( '/xrpc/com.atproto.space.getSpace', ); }); }); describe('writing', () => { it('names the caller as the repo, which the host requires', async () => { authedFetch.mockResolvedValue(answers({ uri: 'at://x', cid: 'y' })); await createRecord(session, { space: SPACE, collection: 'com.example.post', record: {}, }); const init = authedFetch.mock.calls[0][2]; expect(init.method).toBe('POST'); expect(JSON.parse(init.body).repo).toBe(ALICE); }); it("goes to the signed-in account's own PDS whatever the authority is", async () => { authedFetch.mockResolvedValue(answers({})); await deleteRecord(session, { space: `at://${BOB}/space/com.example.forum/general`, collection: 'com.example.post', rkey: '3k2a', }); expect(authedFetch.mock.calls[0][1]).toBe( 'https://alice.example/xrpc/com.atproto.space.deleteRecord', ); }); it('creates a space under the memberspace policy', async () => { authedFetch.mockResolvedValue(answers({ uri: SPACE })); await createSpace(session, { type: 'com.example.forum', skey: 'general' }); expect(authedFetch.mock.calls[0][1]).toBe( 'https://alice.example/xrpc/com.atproto.space.createSpace', ); expect(JSON.parse(authedFetch.mock.calls[0][2].body)).toEqual({ spaceType: 'com.example.forum', skey: 'general', policy: { $type: 'com.atproto.memberspace.policy', publicRead: false, publicWrite: false, publishPolicy: false, publishMembers: false, }, }); }); }); describe('an error answer', () => { it('carries the name the method returned, which a screen reads', async () => { authedFetch.mockResolvedValue( answers({ error: 'RecordNotFound', message: 'Record not found' }, 404), ); const failure = await listRecords(session, { space: SPACE }).catch( (e) => e, ); expect(failure).toBeInstanceOf(XrpcError); expect(failure.name).toBe('RecordNotFound'); expect(failure.status).toBe(404); }); it('says something useful when the body is not an XRPC error at all', async () => { authedFetch.mockResolvedValue( new Response('gateway timeout', { status: 504 }), ); const failure = await listRecords(session, { space: SPACE }).catch( (e) => e, ); expect(failure.name).toBe('RequestFailed'); expect(failure.message).toContain('504'); }); });