~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
380 lines · 11.7 KB Raw History
// 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({});
  });
});