chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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<string|null>,
* 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<typeof createIdentityHandlers>} 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<Response>} */ (
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({});
});
});