// Identity handler tests - handle resolution, and the checks a rename must pass import { afterEach, describe, expect, it, vi } from 'vitest'; import { generateKeyPair, importPrivateKey, } from '../packages/core/src/crypto.js'; import { createIdentityHandlers } from '../packages/core/src/handlers/xrpc-identity.js'; const DID = 'did:plc:identitytestaccount23456'; const HANDLE = 'alice.example.com'; afterEach(() => { vi.unstubAllGlobals(); }); /** * @param {{ * did?: string|null, handle?: string|null, hostname?: string, * resolveHandle?: (handle: string) => Promise, * readOnly?: boolean, withKey?: boolean, proxied?: Response|null, * }} [options] */ async function createIdentity({ did = DID, handle = HANDLE, hostname = 'example.com', resolveHandle = async () => null, readOnly = false, withKey = true, proxied = null, } = {}) { const { privateKey } = await generateKeyPair(); const signingKey = withKey ? await importPrivateKey(privateKey) : null; /** @type {{handle: string|null}} */ const actor = { handle }; /** @type {Array<{did: string, handle: string, createdAt: number}>} */ const actors = []; /** @type {Array<{type: string, body: Object}>} */ const events = []; const identity = createIdentityHandlers({ actorStorage: /** @type {any} */ ({ async getHandle() { return actor.handle; }, async setHandle(/** @type {string} */ h) { actor.handle = h; }, async getPrivateKey() { return withKey ? privateKey : null; }, async getKemKey() { return null; }, }), sharedStorage: /** @type {any} */ ({ resolveHandle, async getActor() { return { createdAt: 1700000000000 }; }, async putActor( /** @type {string} */ d, /** @type {string} */ h, /** @type {number} */ createdAt, ) { actors.push({ did: d, handle: h, createdAt }); }, }), hostname, plcUrl: 'https://plc.example.com', jwtSecret: 'test-secret', readOnly, emailer: null, k256Signer: null, getDid: async () => did, getSigningKey: async () => signingKey, handleProxyHeader: async () => proxied, emitEvent: async (type, body) => { events.push({ type, body }); }, readJsonBounded: async (request) => request.json(), readOnlyError: () => Response.json( { error: 'AuthenticationRequired', message: 'This PDS is read-only' }, { status: 401 }, ), }); return { identity, actor, actors, events }; } /** * @param {ReturnType} identity * @param {string} path * @param {{body?: Object, auth?: {did: string, scope: string}|null}} [options] */ async function call(identity, path, { body, auth = null } = {}) { const route = identity.routes[path.split('?')[0]]; const url = new URL(`https://example.com${path}`); const request = new Request(url, { method: body ? 'POST' : 'GET', ...(body ? { body: JSON.stringify(body) } : {}), }); const handler = /** @type {(request: Request, url: URL, auth: unknown) => Promise} */ ( route.handler ); return handler(request, url, auth); } const RESOLVE = '/xrpc/com.atproto.identity.resolveHandle'; const UPDATE = '/xrpc/com.atproto.identity.updateHandle'; describe('resolveHandle', () => { it('answers from shared storage', async () => { const { identity } = await createIdentity({ resolveHandle: async () => 'did:plc:someoneelse', }); const body = await ( await call(identity, `${RESOLVE}?handle=bob.com`) ).json(); expect(body.did).toBe('did:plc:someoneelse'); }); it('falls back to the hosted account', async () => { const { identity } = await createIdentity(); const body = await ( await call(identity, `${RESOLVE}?handle=${HANDLE}`) ).json(); expect(body.did).toBe(DID); }); it('needs a handle parameter', async () => { const { identity } = await createIdentity(); const response = await call(identity, RESOLVE); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/Missing handle/); }); it('is a 400 for a handle it cannot place', async () => { const { identity } = await createIdentity(); const response = await call(identity, `${RESOLVE}?handle=nobody.com`); expect(response.status).toBe(400); expect((await response.json()).message).toMatch(/not found/); }); it('hands the request to the proxy when a header names a service', async () => { const { identity } = await createIdentity({ proxied: new Response('proxied', { status: 299 }), }); const response = await call(identity, `${RESOLVE}?handle=${HANDLE}`); expect(response.status).toBe(299); }); }); describe('renameHandle', () => { it('refuses a handle that is not one', async () => { const { identity } = await createIdentity(); await expect(identity.renameHandle('not a handle')).rejects.toThrow( /Invalid handle/, ); }); it('refuses when the server has no key', async () => { const { identity } = await createIdentity({ withKey: false }); await expect(identity.renameHandle('bob.com')).rejects.toThrow( /not initialized/, ); }); it('refuses a handle another account holds', async () => { const { identity } = await createIdentity({ resolveHandle: async () => 'did:plc:someoneelse', }); await expect(identity.renameHandle('bob.com')).rejects.toThrow( /already in use/, ); }); it('does nothing when the handle is unchanged', async () => { const { identity, events } = await createIdentity(); expect(await identity.renameHandle(HANDLE)).toEqual({ handle: HANDLE, changed: false, }); expect(events).toHaveLength(0); }); it('lowercases before comparing, so a mixed-case repeat is unchanged', async () => { const { identity } = await createIdentity(); expect(await identity.renameHandle('Alice.Example.COM')).toEqual({ handle: HANDLE, changed: false, }); }); // This is the case the e2e suite covers only with docker infrastructure. it('explains why a subdomain of the server is refused', async () => { const { identity } = await createIdentity({ handle: 'example.com' }); await expect(identity.renameHandle('bob.example.com')).rejects.toThrow( /subdomain of this server's own address \(example\.com\)/, ); }); it('refuses an outside domain that does not point here yet', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => ({ ok: true, json: async () => ({ Answer: [] }) })), ); const { identity } = await createIdentity(); await expect(identity.renameHandle('bob.com')).rejects.toThrow( /does not point to this account yet/, ); }); it('submits a chained PLC operation and announces the change', async () => { /** @type {Array<{url: string, init?: RequestInit}>} */ const calls = []; vi.stubGlobal( 'fetch', vi.fn(async (/** @type {string} */ url, init) => { calls.push({ url: String(url), init }); const u = String(url); if (u.includes('dns-query')) { return { ok: true, json: async () => ({ Answer: [{ type: 16, data: `"did=${DID}"` }], }), }; } if (u.endsWith('/log/last')) { return { ok: true, json: async () => ({ cid: 'bafyreiprevop' }) }; } return { ok: true, text: async () => '' }; }), ); const { identity, actor, actors, events } = await createIdentity(); const result = await identity.renameHandle('bob.com'); expect(result).toEqual({ handle: 'bob.com', changed: true }); const submit = calls.find( (c) => c.init?.method === 'POST' && c.url.includes('plc.example.com'), ); const operation = JSON.parse(String(submit?.init?.body)); expect(operation.type).toBe('plc_operation'); expect(operation.alsoKnownAs).toEqual(['at://bob.com']); expect(operation.prev).toBe('bafyreiprevop'); expect(operation.sig).toBeTruthy(); expect(actor.handle).toBe('bob.com'); // The original creation time survives the rename. expect(actors[0].createdAt).toBe(1700000000000); expect(events).toEqual([{ type: 'identity', body: { handle: 'bob.com' } }]); }); it('reports what the PLC directory said when it refuses', async () => { vi.stubGlobal( 'fetch', vi.fn(async (/** @type {string} */ url, init) => { const u = String(url); if (u.includes('dns-query')) { return { ok: true, json: async () => ({ Answer: [{ type: 16, data: `"did=${DID}"` }], }), }; } if (u.endsWith('/log/last')) return { ok: false }; if (init?.method === 'POST') { return { ok: false, status: 409, text: async () => 'stale prev' }; } return { ok: false }; }), ); const { identity } = await createIdentity(); await expect(identity.renameHandle('bob.com')).rejects.toThrow( /PLC directory rejected operation: 409 stale prev/, ); }); it('submits no operation for a did:web account', async () => { /** @type {string[]} */ const posts = []; vi.stubGlobal( 'fetch', vi.fn(async (/** @type {string} */ url, init) => { if (init?.method === 'POST') posts.push(String(url)); if (String(url).includes('dns-query')) { return { ok: true, json: async () => ({ Answer: [{ type: 16, data: '"did=did:web:bob.com"' }], }), }; } return { ok: true, text: async () => '' }; }), ); const { identity, events } = await createIdentity({ did: 'did:web:bob.com', }); expect(await identity.renameHandle('bob.com')).toEqual({ handle: 'bob.com', changed: true, }); expect(posts).toHaveLength(0); expect(events).toHaveLength(1); }); it('takes the server’s own address without a lookup', async () => { const resolver = vi.fn(async () => ({ ok: false })); vi.stubGlobal('fetch', resolver); const { identity, events } = await createIdentity({ did: 'did:web:example.com', handle: 'old.example.net', }); expect(await identity.renameHandle('example.com')).toEqual({ handle: 'example.com', changed: true, }); expect(resolver).not.toHaveBeenCalled(); expect(events).toHaveLength(1); }); }); describe('updateHandle', () => { it('refuses a read-only server', async () => { const { identity } = await createIdentity({ readOnly: true }); const response = await call(identity, UPDATE, { body: { handle: 'b.com' }, }); expect(response.status).toBe(401); }); it('needs a session', async () => { const { identity } = await createIdentity(); const response = await call(identity, UPDATE, { body: { handle: 'b.com' }, }); expect(response.status).toBe(401); expect((await response.json()).error).toBe('AuthenticationRequired'); }); it('reports a rename failure as InvalidRequest', async () => { const { identity } = await createIdentity(); const response = await call(identity, UPDATE, { body: { handle: 'not a handle' }, auth: { did: DID, scope: 'com.atproto.access' }, }); expect(response.status).toBe(400); const body = await response.json(); expect(body.error).toBe('InvalidRequest'); expect(body.message).toMatch(/Invalid handle/); }); it('answers with an empty body on success', async () => { const { identity } = await createIdentity(); const response = await call(identity, UPDATE, { body: { handle: HANDLE }, auth: { did: DID, scope: 'com.atproto.access' }, }); expect(response.status).toBe(200); expect(await response.json()).toEqual({}); }); });