// Server root endpoint tests - health, root, atproto-did, status, /init, and // the two endpoints that announce repo state import { describe, expect, it } from 'vitest'; import { decodeStoredKey } from '../packages/core/src/crypto.js'; import { createServerMetaHandlers } from '../packages/core/src/handlers/server-meta.js'; const DID = 'did:plc:servermetaaccount2345672'; const KEY_HEX = 'ab'.repeat(32); /** * @param {{ * did?: string|null, handle?: string|null, readOnly?: boolean, * auth?: {did: string, scope: string}|null, k256?: boolean, * latestCommit?: {cid: string, rev: string}|null, * rebuildMst?: () => Promise<{rev: string, cid: string, changed: boolean}|null>, * }} [options] */ function createMeta({ did = null, handle = null, readOnly = false, auth = null, k256 = false, latestCommit = null, rebuildMst = async () => null, } = {}) { const stored = { did, handle, /** @type {Uint8Array|null} */ privateKey: null, }; /** @type {Array<{type: string, body: Object}>} */ const events = []; /** @type {Object[]} */ const syncEvents = []; /** @type {Array<{did: string, handle: string|null}>} */ const actors = []; let cacheCleared = 0; const handlers = createServerMetaHandlers({ actorStorage: /** @type {any} */ ({ async getDid() { return stored.did; }, async setDid(/** @type {string} */ d) { stored.did = d; }, async getHandle() { return stored.handle; }, async setHandle(/** @type {string} */ h) { stored.handle = h; }, async setPrivateKey(/** @type {Uint8Array} */ k) { stored.privateKey = k; }, async getLatestCommit() { return latestCommit; }, async getAccountStatus() { return 'active'; }, }), sharedStorage: /** @type {any} */ ({ async putActor(/** @type {string} */ d, /** @type {string|null} */ h) { actors.push({ did: d, handle: h }); }, }), readOnly, version: '9.9.9', k256Signer: k256 ? /** @type {any} */ ({}) : undefined, getDid: async () => stored.did, authenticate: async () => auth, emitEvent: async (type, body) => { events.push({ type, body }); }, emitSyncEvent: async (commit) => { syncEvents.push(commit); }, rebuildMst, readOnlyError: () => Response.json( { error: 'AuthenticationRequired', message: 'This PDS is read-only' }, { status: 401 }, ), setCachedIdentity: () => { cacheCleared += 1; }, }); return { handlers, stored, events, syncEvents, actors, cleared: () => cacheCleared, }; } /** * @param {ReturnType} handlers * @param {string} path * @param {Object} [body] * @param {{did: string, scope: string}|null} [auth] - What dispatch resolved, for the routes it authenticates */ async function call(handlers, path, body, auth = null) { const route = handlers.routes[path]; const url = new URL(`https://pds.example.com${path}`); /** @type {RequestInit} */ const init = { method: body ? 'POST' : 'GET' }; if (body) init.body = JSON.stringify(body); const handler = /** @type {(request: Request, url: URL, auth: {did: string, scope: string}|null) => Promise} */ ( route.handler ); return handler(new Request(url, init), url, auth); } describe('root and health', () => { it('serves the banner as plain text', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/'); expect(response.headers.get('Content-Type')).toBe( 'text/plain; charset=utf-8', ); expect(await response.text()).toContain('ATProto PDS'); }); it('answers health with the version', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/xrpc/_health'); expect(response.status).toBe(200); expect((await response.json()).version).toBe('9.9.9'); }); }); describe('atproto-did', () => { it('serves the DID once one is adopted', async () => { const { handlers } = createMeta({ did: DID }); const response = await call(handlers, '/.well-known/atproto-did'); expect(response.status).toBe(200); expect((await response.text()).trim()).toBe(DID); }); it('is a 404 before one is', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/.well-known/atproto-did'); expect(response.status).toBe(404); }); }); describe('status', () => { it('reports whether an identity is adopted', async () => { const { handlers } = createMeta({ did: DID, handle: 'alice.example.com' }); const body = await (await call(handlers, '/status')).json(); expect(body.initialized).toBe(true); expect(body.did).toBe(DID); }); it('reports an empty server as uninitialized', async () => { const { handlers } = createMeta(); const body = await (await call(handlers, '/status')).json(); expect(body.initialized).toBe(false); expect(body.did).toBe(null); }); }); describe('init', () => { it('adopts an identity on an empty server without auth', async () => { const { handlers, stored, events, actors, cleared } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: KEY_HEX, handle: 'alice.example.com', }); expect(response.status).toBe(200); expect(stored.did).toBe(DID); expect(stored.handle).toBe('alice.example.com'); expect( decodeStoredKey(/** @type {Uint8Array} */ (stored.privateKey)).curve, ).toBe('p256'); expect(actors).toEqual([{ did: DID, handle: 'alice.example.com' }]); expect(cleared()).toBe(1); // A fresh bootstrap announces both the identity and the account. expect(events.map((e) => e.type)).toEqual(['identity', 'account']); }); it('announces a sync event when a repo already exists', async () => { const { handlers, syncEvents } = createMeta({ latestCommit: { cid: 'bafycommit', rev: 'rev1' }, }); await call(handlers, '/init', { did: DID, privateKey: KEY_HEX }); expect(syncEvents).toEqual([{ cid: 'bafycommit', rev: 'rev1' }]); }); // Once a DID is adopted this endpoint can repoint the account at another // identity, so it stops being open. it('needs the account credentials once a DID is adopted', async () => { const { handlers, stored } = createMeta({ did: DID }); const response = await call(handlers, '/init', { did: 'did:plc:attackeraccount234567234', privateKey: KEY_HEX, }); expect(response.status).toBe(401); expect((await response.json()).message).toMatch(/already initialized/); expect(stored.did).toBe(DID); }); it('lets the account itself repoint the server', async () => { const other = 'did:plc:secondaccount23456723456'; const { handlers, stored } = createMeta({ did: DID, auth: { did: DID, scope: 'com.atproto.access' }, }); const response = await call(handlers, '/init', { did: other, privateKey: KEY_HEX, }); expect(response.status).toBe(200); expect(stored.did).toBe(other); }); it('refuses a read-only server', async () => { const { handlers, stored } = createMeta({ readOnly: true }); const response = await call(handlers, '/init', { did: DID, privateKey: KEY_HEX, }); expect(response.status).toBe(401); expect(stored.did).toBe(null); }); it('needs a did and a privateKey', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/init', { did: DID }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch( /missing did or privateKey/, ); }); it('refuses a did it cannot parse', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/init', { did: 'not-a-did', privateKey: KEY_HEX, }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/invalid DID format/); }); it('refuses a privateKey that is not hex', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: 'zz'.repeat(32), }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/valid hex/); }); it('takes a privateKey as a byte array', async () => { const { handlers, stored } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: Array.from({ length: 32 }, () => 7), }); expect(response.status).toBe(200); expect(stored.privateKey).toBeTruthy(); }); it('refuses a privateKey of the wrong length', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: 'ab'.repeat(16), }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/32 bytes/); }); it('refuses a privateKey that is neither hex nor bytes', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: { nope: true }, }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/hex string or byte array/); }); it('refuses a curve it does not know', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: KEY_HEX, curve: 'ed25519', }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/p256 or secp256k1/); }); // secp256k1 needs the injected signer, so a deployment without one must say so // rather than storing a key it cannot sign with. it('refuses secp256k1 without an injected signer', async () => { const { handlers, stored } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: KEY_HEX, curve: 'secp256k1', }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch( /cannot sign with secp256k1/, ); expect(stored.privateKey).toBe(null); }); it('stores a secp256k1 key when a signer is present', async () => { const { handlers, stored } = createMeta({ k256: true }); const response = await call(handlers, '/init', { did: DID, privateKey: KEY_HEX, curve: 'secp256k1', }); expect(response.status).toBe(200); expect( decodeStoredKey(/** @type {Uint8Array} */ (stored.privateKey)).curve, ).toBe('secp256k1'); }); it('refuses a handle it cannot parse', async () => { const { handlers } = createMeta(); const response = await call(handlers, '/init', { did: DID, privateKey: KEY_HEX, handle: 'not a handle', }); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/invalid handle format/); }); }); const ACCESS = { did: DID, scope: 'com.atproto.access' }; const OTHER_DID = 'did:plc:secondaccount23456723456'; const COMMIT = { cid: 'bafycommitcid', rev: '3kzz' }; const SYNC_PATH = '/xrpc/com.atproto.sync.requestSync'; const REBUILD_PATH = '/xrpc/dev.pdsjs.repo.rebuildMst'; describe('requestSync', () => { it('announces the current head', async () => { const { handlers, syncEvents } = createMeta({ did: DID, latestCommit: COMMIT, }); const response = await call(handlers, SYNC_PATH, {}, ACCESS); expect(response.status).toBe(200); expect(await response.json()).toEqual({ rev: COMMIT.rev, cid: COMMIT.cid, }); expect(syncEvents).toEqual([COMMIT]); }); it('refuses a read-only server', async () => { const { handlers, syncEvents } = createMeta({ did: DID, latestCommit: COMMIT, readOnly: true, }); const response = await call(handlers, SYNC_PATH, {}, ACCESS); expect(response.status).toBe(401); expect(syncEvents).toEqual([]); }); it('refuses a server with no DID', async () => { const { handlers, syncEvents } = createMeta({ latestCommit: COMMIT }); const response = await call(handlers, SYNC_PATH, {}, ACCESS); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/not initialized/); expect(syncEvents).toEqual([]); }); it('refuses an unauthenticated caller', async () => { const { handlers, syncEvents } = createMeta({ did: DID, latestCommit: COMMIT, }); const response = await call(handlers, SYNC_PATH, {}); expect(response.status).toBe(403); expect(syncEvents).toEqual([]); }); it('refuses another account', async () => { const { handlers, syncEvents } = createMeta({ did: DID, latestCommit: COMMIT, }); const response = await call( handlers, SYNC_PATH, {}, { did: OTHER_DID, scope: 'com.atproto.access', }, ); expect(response.status).toBe(403); expect(syncEvents).toEqual([]); }); it('refuses a repo with no commits', async () => { const { handlers, syncEvents } = createMeta({ did: DID }); const response = await call(handlers, SYNC_PATH, {}, ACCESS); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/no commits/); expect(syncEvents).toEqual([]); }); }); describe('rebuildMst', () => { const rebuilt = { rev: '3kzz', cid: 'bafyrebuiltroot', changed: true }; it('returns the rebuilt head', async () => { let calls = 0; const { handlers } = createMeta({ did: DID, rebuildMst: async () => { calls += 1; return rebuilt; }, }); const response = await call(handlers, REBUILD_PATH, {}, ACCESS); expect(response.status).toBe(200); expect(await response.json()).toEqual(rebuilt); expect(calls).toBe(1); }); it('refuses a read-only server', async () => { const { handlers } = createMeta({ did: DID, readOnly: true, rebuildMst: async () => rebuilt, }); const response = await call(handlers, REBUILD_PATH, {}, ACCESS); expect(response.status).toBe(401); }); it('refuses a server with no DID', async () => { const { handlers } = createMeta({ rebuildMst: async () => rebuilt }); const response = await call(handlers, REBUILD_PATH, {}, ACCESS); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/not initialized/); }); it('refuses an unauthenticated caller', async () => { let calls = 0; const { handlers } = createMeta({ did: DID, rebuildMst: async () => { calls += 1; return rebuilt; }, }); const response = await call(handlers, REBUILD_PATH, {}); expect(response.status).toBe(403); expect(calls).toBe(0); }); it('refuses another account', async () => { let calls = 0; const { handlers } = createMeta({ did: DID, rebuildMst: async () => { calls += 1; return rebuilt; }, }); const response = await call( handlers, REBUILD_PATH, {}, { did: OTHER_DID, scope: 'com.atproto.access', }, ); expect(response.status).toBe(403); expect(calls).toBe(0); }); it('refuses a repo with no commits', async () => { const { handlers } = createMeta({ did: DID }); const response = await call(handlers, REBUILD_PATH, {}, ACCESS); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/no commits/); }); });