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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
914 lines · 29.8 KB Raw History
// com.atproto.server.* handler tests: the refusals each endpoint owes a
// caller, and the two paths by which an account arrives on an empty server.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  createAccessJwt,
  createRefreshJwt,
} from '../packages/core/src/auth.js';
import {
  generateKeyPair,
  importPrivateKey,
} from '../packages/core/src/crypto.js';
import { createXrpcServerHandlers } from '../packages/core/src/handlers/xrpc-server.js';
import { kemDidKey } from '../packages/core/src/kem.js';
import { publicKeyToDidKey } from '../packages/core/src/plc.js';
import { createSessionRegistry } from '../packages/core/src/session.js';

const DID = 'did:plc:xrpcserveraccount2345672';
const HOSTNAME = 'pds.example.com';
const SECRET = 'test-secret';
const PASSWORD = 'operator-password';

/**
 * @param {{
 *   did?: string|null, handle?: string|null, readOnly?: boolean,
 *   noPassword?: boolean, appPasswords?: boolean,
 *   verifier?: {verify: (didKey: string, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}|null,
 *   didResolver?: ((did: string) => Promise<Object|null>)|null,
 *   spacesEnabled?: boolean,
 * }} [options]
 */
function createServer({
  did = null,
  handle = null,
  readOnly = false,
  noPassword = false,
  appPasswords = true,
  verifier = null,
  didResolver = null,
  spacesEnabled = false,
} = {}) {
  const stored = {
    did,
    handle,
    /** @type {Uint8Array|null} */ privateKey: null,
    /** @type {Uint8Array|null} */ kemKey: null,
    /** @type {{address: string, verified: boolean}|null} */ email: null,
    /** @type {string|null} */ status: null,
  };
  /** @type {Map<string, any>} */
  const sessionRows = new Map();
  /** @type {Map<string, any>} */
  const appPasswordRows = new Map();
  /** @type {Array<{type: string, body: Object}>} */
  const events = [];
  /** @type {string[]} */
  const crawled = [];

  const sharedStorage = /** @type {any} */ ({
    putSession: async (/** @type {any} */ s) =>
      void sessionRows.set(s.jti, { ...s }),
    getSession: async (/** @type {string} */ jti) =>
      sessionRows.get(jti) || null,
    listSessions: async () => [...sessionRows.values()],
    deleteSession: async (/** @type {string} */ jti) => sessionRows.delete(jti),
    putActor: async () => {},
    listAppPasswords: async () => [...appPasswordRows.values()],
    deleteAppPassword: async (
      /** @type {string} */ _d,
      /** @type {string} */ n,
    ) => appPasswordRows.delete(n),
    putAppPassword: async (
      /** @type {string} */ _d,
      /** @type {string} */ name,
      /** @type {string} */ hash,
      /** @type {boolean} */ privileged,
      /** @type {string} */ createdAt,
    ) => {
      if (appPasswordRows.has(name)) return false;
      appPasswordRows.set(name, { name, hash, privileged, createdAt });
      return true;
    },
  });
  // A deployment whose storage predates app passwords answers no lookup.
  if (appPasswords) {
    sharedStorage.getAppPasswordByHash = async (
      /** @type {string} */ _d,
      /** @type {string} */ hash,
    ) => [...appPasswordRows.values()].find((r) => r.hash === hash) || null;
  }

  const handlers = createXrpcServerHandlers({
    actorStorage: /** @type {any} */ ({
      getDid: async () => stored.did,
      setDid: async (/** @type {string} */ d) => {
        stored.did = d;
      },
      getHandle: async () => stored.handle,
      setHandle: async (/** @type {string} */ h) => {
        stored.handle = h;
      },
      setPrivateKey: async (/** @type {Uint8Array} */ k) => {
        stored.privateKey = k;
      },
      getKemKey: async () => stored.kemKey ?? null,
      setKemKey: async (/** @type {Uint8Array} */ k) => {
        stored.kemKey = k;
      },
      setEmail: async (/** @type {any} */ e) => {
        stored.email = e;
      },
      setAccountStatus: async (/** @type {string} */ s) => {
        stored.status = s;
      },
    }),
    sharedStorage,
    sessions: createSessionRegistry(sharedStorage),
    jwtSecret: SECRET,
    hostname: HOSTNAME,
    plcUrl: 'https://plc.example.com',
    blobUploadLimit: 50 * 1024 * 1024,
    password: noPassword ? undefined : PASSWORD,
    verifyAccountPassword: async (candidate) =>
      !noPassword && candidate === PASSWORD,
    passwordConfigured: async () => !noPassword,
    readOnly,
    spacesEnabled,
    verifier: /** @type {any} */ (verifier),
    didResolver,
    getDid: async () => stored.did,
    getSigningKey: async () => null,
    checkRpcScope: () => null,
    generateSigningKey: async () => {
      const keyPair = await generateKeyPair();
      return {
        stored: keyPair.privateKey,
        didKey: publicKeyToDidKey(keyPair.publicKey),
        signingKey: await importPrivateKey(keyPair.privateKey),
      };
    },
    emailSessionFields: async () => ({}),
    emitEvent: async (type, body) => {
      events.push({ type, body });
    },
    notifyRelay: async (d) => {
      crawled.push(d);
    },
    readOnlyError: () =>
      Response.json(
        { error: 'AuthenticationRequired', message: 'This PDS is read-only' },
        { status: 401 },
      ),
    setCachedIdentity: () => {},
  });

  return { handlers, stored, sessionRows, appPasswordRows, events, crawled };
}

/**
 * @param {ReturnType<typeof createXrpcServerHandlers>} handlers
 * @param {string} path
 * @param {{body?: unknown, raw?: string, headers?: Record<string, string>, auth?: {did: string, scope: string}|null}} [options]
 */
async function call(handlers, path, { body, raw, headers, auth = null } = {}) {
  const route = handlers.routes[path];
  const url = new URL(`https://${HOSTNAME}${path}`);
  /** @type {RequestInit} */
  const init = { method: route.method || 'GET', headers };
  if (raw !== undefined) init.body = raw;
  else if (body !== undefined) init.body = JSON.stringify(body);
  const handler =
    /** @type {(request: Request, url: URL, auth: {did: string, scope: string}|null) => Promise<Response>} */ (
      route.handler
    );
  return handler(new Request(url, init), url, auth);
}

const P = {
  describe: '/xrpc/com.atproto.server.describeServer',
  createSession: '/xrpc/com.atproto.server.createSession',
  createAccount: '/xrpc/com.atproto.server.createAccount',
  getSession: '/xrpc/com.atproto.server.getSession',
  createAppPassword: '/xrpc/com.atproto.server.createAppPassword',
  listAppPasswords: '/xrpc/com.atproto.server.listAppPasswords',
  revokeAppPassword: '/xrpc/com.atproto.server.revokeAppPassword',
  deleteSession: '/xrpc/com.atproto.server.deleteSession',
  refreshSession: '/xrpc/com.atproto.server.refreshSession',
  onboarding: '/account/onboarding/create',
};

const FULL = { did: DID, scope: 'com.atproto.access' };
const APP_PASS = { did: DID, scope: 'com.atproto.appPass' };

afterEach(() => {
  vi.unstubAllGlobals();
});

describe('describeServer', () => {
  it('names the did:web identity before an account is hosted', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.describe);
    const body = await response.json();

    expect(body.did).toBe(`did:web:${HOSTNAME}`);
    expect(body.availableUserDomains).toEqual([`.${HOSTNAME}`]);
  });

  it('reports the largest blob it accepts', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.describe);
    const body = await response.json();

    expect(body.blobUploadLimit).toBe(50 * 1024 * 1024);
  });

  it('says it serves the spaces alpha only when spaces are on', async () => {
    const off = await call(createServer().handlers, P.describe);
    expect((await off.json()).spacesAlpha).toBeUndefined();

    const on = await call(
      createServer({ spacesEnabled: true }).handlers,
      P.describe,
    );
    expect((await on.json()).spacesAlpha).toBe(true);
  });
});

describe('createSession', () => {
  it('refuses a login on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, P.createSession, {
      body: { identifier: DID, password: PASSWORD },
    });

    expect(response.status).toBe(401);
  });

  it('reports an uninitialized server', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createSession, {
      body: { identifier: DID, password: PASSWORD },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toBe('PDS not initialized');
  });

  it('refuses an identifier that is neither the DID nor the handle', async () => {
    const { handlers } = createServer({ did: DID, handle: 'alice.test' });
    const response = await call(handlers, P.createSession, {
      body: { identifier: 'bob.test', password: PASSWORD },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe('Invalid identifier');
  });

  it('refuses every password when none is configured', async () => {
    const { handlers } = createServer({ did: DID, noPassword: true });
    const response = await call(handlers, P.createSession, {
      body: { identifier: DID, password: '' },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toContain(
      'Password login is not configured',
    );
  });

  it('opens a session for the account password and records it', async () => {
    const { handlers, sessionRows } = createServer({
      did: DID,
      handle: 'alice.test',
    });
    const response = await call(handlers, P.createSession, {
      body: { identifier: 'alice.test', password: PASSWORD },
    });
    const body = await response.json();

    expect(body.did).toBe(DID);
    expect(body.handle).toBe('alice.test');
    expect(body.accessJwt).toBeTruthy();
    expect([...sessionRows.values()][0].label).toBe('Account password');
  });

  it('opens a restricted session for an app password', async () => {
    const { handlers, sessionRows } = createServer({ did: DID });
    const created = await call(handlers, P.createAppPassword, {
      body: { name: 'bot' },
      auth: FULL,
    });
    const { password } = await created.json();

    const response = await call(handlers, P.createSession, {
      body: { identifier: DID, password },
      auth: null,
    });
    const body = await response.json();

    expect(body.did).toBe(DID);
    expect([...sessionRows.values()][0].label).toBe('App password: bot');
    expect([...sessionRows.values()][0].scope).toBe('com.atproto.appPass');
  });

  it('refuses a wrong password', async () => {
    const { handlers } = createServer({ did: DID });
    const response = await call(handlers, P.createSession, {
      body: { identifier: DID, password: 'wrong' },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe('Invalid password');
  });

  it('refuses an app password when storage keeps none', async () => {
    const { handlers } = createServer({ did: DID, appPasswords: false });
    const response = await call(handlers, P.createSession, {
      body: { identifier: DID, password: 'whatever' },
    });

    expect(response.status).toBe(401);
  });
});

describe('createAccount', () => {
  /** @param {Record<string, unknown>} claims */
  function serviceToken(claims) {
    const encode = (/** @type {Object} */ part) =>
      btoa(JSON.stringify(part))
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=+$/, '');
    return `${encode({ alg: 'ES256' })}.${encode(claims)}.c2ln`;
  }

  /** @param {number} [offset] - Seconds from now the token expires */
  const migrationClaims = (offset = 60) => ({
    iss: DID,
    aud: `did:web:${HOSTNAME}`,
    lxm: 'com.atproto.server.createAccount',
    exp: Math.floor(Date.now() / 1000) + offset,
  });

  it('refuses a migration on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
    });

    expect(response.status).toBe(401);
  });

  it('refuses a body that is not JSON', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAccount, { raw: 'not json' });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toBe('Invalid request body');
  });

  it('refuses a second account', async () => {
    const { handlers } = createServer({ did: DID });
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toBe(
      'This server already hosts an account',
    );
  });

  it('requires an existing DID', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAccount, {
      body: { handle: 'alice.test' },
    });

    expect((await response.json()).error).toBe('UnsupportedDomain');
  });

  it('requires a valid handle', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'not a handle' },
    });

    expect((await response.json()).error).toBe('InvalidHandle');
  });

  it('adopts the DID and stores the email the migration carries', async () => {
    const { handlers, stored } = createServer();
    const response = await call(handlers, P.createAccount, {
      body: {
        did: DID,
        handle: 'alice.test',
        email: 'alice@example.com',
        password: PASSWORD,
      },
    });
    const body = await response.json();

    expect(body.did).toBe(DID);
    expect(stored.status).toBe('deactivated');
    // The PLC operation the migration submits publishes it.
    expect(stored.kemKey).toHaveLength(32);
    expect(stored.email).toEqual({
      address: 'alice@example.com',
      verified: false,
    });
  });

  it('refuses a caller with neither a token nor the password', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toContain('service token');
  });

  it('refuses a service token whose claims do not match', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
      headers: {
        authorization: `Bearer ${serviceToken({ ...migrationClaims(), aud: 'did:web:elsewhere.test' })}`,
      },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toContain('Invalid service token');
  });

  it('refuses an expired service token', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
      headers: {
        authorization: `Bearer ${serviceToken(migrationClaims(-60))}`,
      },
    });

    expect(response.status).toBe(401);
  });

  /**
   * The multibase of a real P-256 did:key. The handler reads the curve to
   * check it against the token header's `alg`, which the stub verifier below
   * never sees.
   */
  async function atprotoMultibase() {
    const { publicKey } = await generateKeyPair();
    return publicKeyToDidKey(publicKey).replace('did:key:', '');
  }

  it('accepts a service token whose signature verifies', async () => {
    const multibase = await atprotoMultibase();
    const { handlers } = createServer({
      verifier: { verify: async () => true },
      didResolver: async () => ({
        verificationMethod: [
          { id: `${DID}#atproto`, publicKeyMultibase: multibase },
        ],
      }),
    });
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
      headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
    });

    expect((await response.json()).did).toBe(DID);
  });

  it('refuses a service token whose signature does not verify', async () => {
    const multibase = await atprotoMultibase();
    const { handlers } = createServer({
      verifier: { verify: async () => false },
      didResolver: async () => ({
        verificationMethod: [
          { id: `${DID}#atproto`, publicKeyMultibase: multibase },
        ],
      }),
    });
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
      headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
    });

    expect(response.status).toBe(401);
  });

  it('refuses a token whose issuer publishes no atproto key', async () => {
    const { handlers } = createServer({
      verifier: { verify: async () => true },
      didResolver: async () => ({ verificationMethod: [] }),
    });
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
      headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
    });

    expect(response.status).toBe(401);
  });

  it('refuses a token whose issuer cannot be resolved', async () => {
    const { handlers } = createServer({
      verifier: { verify: async () => true },
      didResolver: async () => {
        throw new Error('unreachable directory');
      },
    });
    const response = await call(handlers, P.createAccount, {
      body: { did: DID, handle: 'alice.test' },
      headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
    });

    expect(response.status).toBe(401);
  });
});

describe('onboarding', () => {
  const signup = { password: PASSWORD, handle: 'alice.example.com' };

  it('refuses onboarding on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, P.onboarding, { body: signup });

    expect(response.status).toBe(401);
  });

  it('refuses a cross-origin post', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.onboarding, {
      body: signup,
      headers: { origin: 'https://attacker.test' },
    });

    expect(response.status).toBe(403);
  });

  it('refuses a body that is not JSON', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.onboarding, { raw: '{' });

    expect(response.status).toBe(400);
  });

  it('refuses to claim a server that already hosts an account', async () => {
    const { handlers } = createServer({ did: DID });
    const response = await call(handlers, P.onboarding, { body: signup });

    expect(response.status).toBe(400);
  });

  it('refuses when no operator password is configured', async () => {
    const { handlers } = createServer({ noPassword: true });
    const response = await call(handlers, P.onboarding, { body: signup });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toContain('PDS_PASSWORD');
  });

  it('refuses a wrong operator password', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.onboarding, {
      body: { ...signup, password: 'wrong' },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe('Invalid server password');
  });

  it('refuses a handle that is not a domain', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.onboarding, {
      body: { ...signup, handle: 'alice' },
    });

    expect((await response.json()).error).toBe('InvalidHandle');
  });

  it('reports a PLC directory it cannot reach', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => {
        throw new Error('connect ECONNREFUSED');
      }),
    );
    const { handlers, stored } = createServer();
    const response = await call(handlers, P.onboarding, { body: signup });

    expect(response.status).toBe(502);
    expect((await response.json()).message).toContain('connect ECONNREFUSED');
    // Nothing local was written, so the operator can retry.
    expect(stored.did).toBeNull();
  });

  it('reports a PLC directory that rejects the identity', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => new Response('handle taken', { status: 400 })),
    );
    const { handlers, stored } = createServer();
    const response = await call(handlers, P.onboarding, { body: signup });

    expect(response.status).toBe(502);
    expect((await response.json()).message).toContain('handle taken');
    expect(stored.did).toBeNull();
  });

  it('registers the identity and opens an account cookie session', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => new Response('', { status: 200 })),
    );
    const { handlers, stored, events, crawled } = createServer();
    const response = await call(handlers, P.onboarding, {
      body: { ...signup, email: 'alice@example.com' },
    });
    const body = await response.json();

    expect(body.ok).toBe(true);
    expect(body.did).toMatch(/^did:plc:[a-z2-7]{24}$/);
    expect(stored.handle).toBe('alice.example.com');
    expect(stored.email?.address).toBe('alice@example.com');
    expect(response.headers.get('Set-Cookie')).toContain('pdsjs_account=');
    expect(events.map((e) => e.type)).toEqual(['identity', 'account']);
    expect(crawled).toEqual([body.did]);
  });

  it('defaults the handle to the server hostname', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn(async () => new Response('', { status: 200 })),
    );
    const { handlers, stored } = createServer();
    await call(handlers, P.onboarding, { body: { password: PASSWORD } });

    expect(stored.handle).toBe(HOSTNAME);
  });

  it('publishes the KEM key it stores in the genesis operation', async () => {
    const fetchMock = vi.fn(async () => new Response('', { status: 200 }));
    vi.stubGlobal('fetch', fetchMock);
    const { handlers, stored } = createServer();
    await call(handlers, P.onboarding, { body: signup });

    const [, init] = /** @type {[string, RequestInit]} */ (
      /** @type {unknown} */ (fetchMock.mock.calls[0])
    );
    const operation = JSON.parse(/** @type {string} */ (init.body));
    expect(stored.kemKey).toHaveLength(32);
    expect(operation.verificationMethods.atproto_kem).toBe(
      await kemDidKey(/** @type {Uint8Array} */ (stored.kemKey)),
    );
  });
});

describe('getSession', () => {
  it('requires authentication', async () => {
    const { handlers } = createServer({ did: DID });
    const response = await call(handlers, P.getSession);

    expect(response.status).toBe(401);
  });

  it('answers with the DID and handle', async () => {
    const { handlers } = createServer({ did: DID, handle: 'alice.test' });
    const response = await call(handlers, P.getSession, { auth: FULL });

    expect(await response.json()).toEqual({ did: DID, handle: 'alice.test' });
  });
});

describe('app passwords', () => {
  it('refuses creation on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, P.createAppPassword, {
      body: { name: 'bot' },
      auth: FULL,
    });

    expect(response.status).toBe(401);
  });

  it('requires authentication to create', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAppPassword, {
      body: { name: 'bot' },
    });

    expect(response.status).toBe(401);
  });

  it('refuses an app-password session managing app passwords', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAppPassword, {
      body: { name: 'bot' },
      auth: APP_PASS,
    });

    expect(response.status).toBe(403);
  });

  it('requires a name', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.createAppPassword, {
      body: {},
      auth: FULL,
    });

    expect(response.status).toBe(400);
  });

  it('refuses a name already taken', async () => {
    const { handlers } = createServer();
    await call(handlers, P.createAppPassword, {
      body: { name: 'bot' },
      auth: FULL,
    });
    const response = await call(handlers, P.createAppPassword, {
      body: { name: 'bot' },
      auth: FULL,
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toContain('already exists');
  });

  it('lists them for a full session only', async () => {
    const { handlers } = createServer();
    await call(handlers, P.createAppPassword, {
      body: { name: 'bot', privileged: true },
      auth: FULL,
    });

    expect((await call(handlers, P.listAppPasswords)).status).toBe(401);
    expect(
      (await call(handlers, P.listAppPasswords, { auth: APP_PASS })).status,
    ).toBe(403);

    const response = await call(handlers, P.listAppPasswords, { auth: FULL });
    const { passwords } = await response.json();
    expect(passwords[0].name).toBe('bot');
    expect(passwords[0].privileged).toBe(true);
  });

  it('refuses revocation on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, P.revokeAppPassword, {
      body: { name: 'bot' },
      auth: FULL,
    });

    expect(response.status).toBe(401);
  });

  it('requires authentication and a full session to revoke', async () => {
    const { handlers } = createServer();

    expect(
      (await call(handlers, P.revokeAppPassword, { body: { name: 'bot' } }))
        .status,
    ).toBe(401);
    expect(
      (
        await call(handlers, P.revokeAppPassword, {
          body: { name: 'bot' },
          auth: APP_PASS,
        })
      ).status,
    ).toBe(403);
  });

  it('requires a name to revoke', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.revokeAppPassword, {
      body: {},
      auth: FULL,
    });

    expect(response.status).toBe(400);
  });

  it('reports a name it does not know', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.revokeAppPassword, {
      body: { name: 'ghost' },
      auth: FULL,
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toContain('No app password named');
  });

  it('ends the logins the credential opened', async () => {
    const { handlers, sessionRows } = createServer({ did: DID });
    const created = await call(handlers, P.createAppPassword, {
      body: { name: 'bot' },
      auth: FULL,
    });
    const { password } = await created.json();
    await call(handlers, P.createSession, {
      body: { identifier: DID, password },
    });
    expect(sessionRows.size).toBe(1);

    const response = await call(handlers, P.revokeAppPassword, {
      body: { name: 'bot' },
      auth: FULL,
    });

    expect(response.status).toBe(200);
    expect(sessionRows.size).toBe(0);
  });
});

describe('deleteSession', () => {
  it('requires a refresh token', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.deleteSession);

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe('Refresh token required');
  });

  it('refuses a token it cannot verify', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.deleteSession, {
      headers: { authorization: 'Bearer not.a.token' },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe('Invalid refresh token');
  });

  it('ends the session its token names', async () => {
    const { handlers, sessionRows } = createServer({ did: DID });
    const opened = await call(handlers, P.createSession, {
      body: { identifier: DID, password: PASSWORD },
    });
    const { refreshJwt } = await opened.json();
    expect(sessionRows.size).toBe(1);

    const response = await call(handlers, P.deleteSession, {
      headers: { authorization: `Bearer ${refreshJwt}` },
    });

    expect(response.status).toBe(200);
    expect(sessionRows.size).toBe(0);
  });
});

describe('refreshSession', () => {
  it('refuses on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, P.refreshSession);

    expect(response.status).toBe(401);
  });

  it('requires an authorization header', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.refreshSession);

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe('Refresh token required');
  });

  it('requires a Bearer scheme', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, P.refreshSession, {
      headers: { authorization: 'Basic abc' },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe(
      'Invalid authorization header',
    );
  });

  it('names an access token used in place of a refresh token', async () => {
    const { handlers } = createServer();
    const accessJwt = await createAccessJwt(DID, SECRET, 3600);
    const response = await call(handlers, P.refreshSession, {
      headers: { authorization: `Bearer ${accessJwt}` },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toContain('Refresh token required');
  });

  it('refuses a token signed with another secret', async () => {
    const { handlers } = createServer();
    const foreign = await createRefreshJwt(DID, 'other-secret', 3600);
    const response = await call(handlers, P.refreshSession, {
      headers: { authorization: `Bearer ${foreign}` },
    });

    expect(response.status).toBe(401);
  });

  it('refuses a token whose session the registry does not hold', async () => {
    const { handlers } = createServer({ did: DID });
    const orphan = await createRefreshJwt(DID, SECRET, 3600);
    const response = await call(handlers, P.refreshSession, {
      headers: { authorization: `Bearer ${orphan}` },
    });

    expect(response.status).toBe(401);
    expect((await response.json()).message).toBe('Session has been revoked');
  });
});