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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
504 lines · 16.5 KB Raw History
// Backup account API tests - the settings the page can change, and what it reads
//
// accountApi arrives through the context, so a stand-in that keeps its contract
// (call the handler with a did and a parsed body, JSON on return, 400 on throw)
// reaches these three routes without a session.
import { describe, expect, it } from 'vitest';
import { defaultBackupState } from '../packages/core/src/backup.js';
import { base64UrlEncode } from '../packages/core/src/crypto.js';
import { createBackupHandlers } from '../packages/core/src/handlers/account-backup.js';

const DID = 'did:plc:backupapiaccount234567';
const KEY = new Uint8Array(32).fill(3);

/** @typedef {{seq: number, cid: string, rev: string}} Head */

/**
 * @param {{withTarget?: boolean, withKey?: boolean, state?: Object|null, key?: Uint8Array, heads?: () => Head, buildFullRepoCar?: (cid: string) => Promise<Uint8Array>}} [options]
 */
function createBackupApi({
  withTarget = true,
  withKey = true,
  state = null,
  key = KEY,
  heads = () => ({ seq: 1, cid: 'bafycommit', rev: 'rev1' }),
  buildFullRepoCar = async () => new Uint8Array([1, 2, 3]),
} = {}) {
  /** @type {{value: Object|null}} */
  const stored = { value: state };
  /** @type {Map<string, Uint8Array>} */
  const objects = new Map();

  const target = {
    async put(/** @type {string} */ k, /** @type {Uint8Array} */ d) {
      objects.set(k, d);
    },
    async get(/** @type {string} */ k) {
      return objects.get(k) || null;
    },
    async list(/** @type {string} */ prefix) {
      return [...objects.keys()].filter((k) => k.startsWith(prefix));
    },
    async delete(/** @type {string} */ k) {
      objects.delete(k);
    },
  };

  const handlers = createBackupHandlers({
    actorStorage: /** @type {any} */ ({
      async getBackupState() {
        return stored.value;
      },
      async setBackupState(/** @type {Object} */ next) {
        stored.value = next;
      },
      async getPrivateKey() {
        return withKey ? key : null;
      },
      async getLatestCommit() {
        return heads();
      },
      async getHandle() {
        return 'alice.example.com';
      },
      async listBlobs() {
        return { cids: [], cursor: null };
      },
    }),
    blobs: /** @type {any} */ ({
      async get() {
        return null;
      },
    }),
    backupTarget: withTarget ? /** @type {any} */ (target) : null,
    hostname: 'pds.example.com',
    getDid: async () => DID,
    buildFullRepoCar,
    // Keeps accountApi's contract: parse, delegate, JSON out, 400 on throw.
    accountApi: async (request, _url, handler) => {
      const body =
        request.method === 'POST' ? await request.json().catch(() => ({})) : {};
      try {
        const result = await handler(DID, body);
        return result instanceof Response
          ? result
          : Response.json(result, { status: 200 });
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        return Response.json({ error: message }, { status: 400 });
      }
    },
  });
  return { handlers, stored, objects };
}

const READ = '/account/api/backups';
const SETTINGS = '/account/api/backups/settings';
const RUN = '/account/api/backups/run';

/**
 * @param {ReturnType<typeof createBackupHandlers>} handlers
 * @param {string} path
 * @param {Object} [body]
 */
async function call(handlers, path, body) {
  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: null) => Promise<Response>} */ (
      route.handler
    );
  return handler(new Request(url, init), url, null);
}

describe('GET backups', () => {
  it('reports defaults on a server that has never run one', async () => {
    const { handlers } = createBackupApi();
    const body = await (await call(handlers, READ)).json();

    expect(body.available).toBe(true);
    expect(body.settings.enabled).toBe(false);
    expect(body.settings.keyBackup).toBe(null);
    expect(body.runs).toEqual([]);
  });

  it('reports unavailable without a target', async () => {
    const { handlers } = createBackupApi({ withTarget: false });
    const body = await (await call(handlers, READ)).json();

    expect(body.available).toBe(false);
  });

  it('hides nextRunAt while backups are off', async () => {
    const state = defaultBackupState();
    state.nextRunAt = 1800000000000;
    const { handlers } = createBackupApi({ state });
    const body = await (await call(handlers, READ)).json();

    expect(body.nextRunAt).toBe(null);
  });
});

describe('POST backups/settings', () => {
  it('turns backups on and schedules the first run', async () => {
    const { handlers, stored } = createBackupApi();
    const body = await (
      await call(handlers, SETTINGS, { enabled: true })
    ).json();

    expect(body.settings.enabled).toBe(true);
    expect(body.nextRunAt).toBeGreaterThan(0);
    expect(/** @type {any} */ (stored.value).config.enabled).toBe(true);
  });

  it('clears the schedule when turned off', async () => {
    const { handlers } = createBackupApi();
    await call(handlers, SETTINGS, { enabled: true });
    const body = await (
      await call(handlers, SETTINGS, { enabled: false })
    ).json();

    expect(body.nextRunAt).toBe(null);
  });

  it('takes an interval and a retention count', async () => {
    const { handlers } = createBackupApi();
    const body = await (
      await call(handlers, SETTINGS, { intervalHours: 12, retain: 7 })
    ).json();

    expect(body.settings.intervalHours).toBe(12);
    expect(body.settings.retain).toBe(7);
  });

  it('holds the interval between 1 and 720 hours', async () => {
    const { handlers } = createBackupApi();
    for (const intervalHours of [0, 721, 'nope']) {
      const response = await call(handlers, SETTINGS, { intervalHours });
      expect(response.status, String(intervalHours)).toBe(400);
      expect((await response.json()).error).toMatch(/between 1 and 720/);
    }
  });

  it('holds retention between 1 and 50 snapshots', async () => {
    const { handlers } = createBackupApi();
    for (const retain of [0, 51, 2.5]) {
      const response = await call(handlers, SETTINGS, { retain });
      expect(response.status, String(retain)).toBe(400);
      expect((await response.json()).error).toMatch(/between 1 and 50/);
    }
  });

  it('refuses settings without a target', async () => {
    const { handlers } = createBackupApi({ withTarget: false });
    const response = await call(handlers, SETTINGS, { enabled: true });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/No backup target/);
  });

  // The browser derives the AES key, so the server only ever sees key, salt and
  // count. It seals the signing key with them.
  it('seals the signing key from a derived key', async () => {
    const { handlers } = createBackupApi();
    const body = await (
      await call(handlers, SETTINGS, {
        keyDerived: {
          key: base64UrlEncode(new Uint8Array(32).fill(9)),
          salt: base64UrlEncode(new Uint8Array(16).fill(1)),
          iterations: 600000,
        },
      })
    ).json();

    expect(body.settings.keyBackup).toBeTruthy();
    expect(body.settings.keyBackup.stale).toBe(false);
  });

  it('takes the sealed key back out', async () => {
    const { handlers } = createBackupApi();
    await call(handlers, SETTINGS, {
      keyDerived: {
        key: base64UrlEncode(new Uint8Array(32).fill(9)),
        salt: base64UrlEncode(new Uint8Array(16).fill(1)),
        iterations: 600000,
      },
    });
    const body = await (
      await call(handlers, SETTINGS, { removeKeyBackup: true })
    ).json();

    expect(body.settings.keyBackup).toBe(null);
  });

  it('needs a key and salt that are strings', async () => {
    const { handlers } = createBackupApi();
    const response = await call(handlers, SETTINGS, {
      keyDerived: { key: 123, salt: 'abc', iterations: 600000 },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/base64url key and salt/);
  });

  it('holds the iteration count in range', async () => {
    const { handlers } = createBackupApi();
    for (const iterations of [99999, 10000001, 1.5]) {
      const response = await call(handlers, SETTINGS, {
        keyDerived: {
          key: base64UrlEncode(new Uint8Array(32)),
          salt: base64UrlEncode(new Uint8Array(16)),
          iterations,
        },
      });
      expect(response.status, String(iterations)).toBe(400);
      expect((await response.json()).error).toMatch(/iterations out of range/);
    }
  });

  it('needs 32 key bytes and at least 8 of salt', async () => {
    const { handlers } = createBackupApi();
    const response = await call(handlers, SETTINGS, {
      keyDerived: {
        key: base64UrlEncode(new Uint8Array(16)),
        salt: base64UrlEncode(new Uint8Array(4)),
        iterations: 600000,
      },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/32 bytes, salt at least 8/);
  });

  it('cannot seal a key the server does not have', async () => {
    const { handlers } = createBackupApi({ withKey: false });
    const response = await call(handlers, SETTINGS, {
      keyDerived: {
        key: base64UrlEncode(new Uint8Array(32)),
        salt: base64UrlEncode(new Uint8Array(16)),
        iterations: 600000,
      },
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/no signing key/);
  });
});

describe('the scheduler', () => {
  it('does nothing without a target', async () => {
    const { handlers } = createBackupApi({ withTarget: false });
    expect(await handlers.maybeRunScheduledBackup()).toBe(null);
  });

  it('does nothing while backups are off', async () => {
    const { handlers } = createBackupApi();
    expect(await handlers.maybeRunScheduledBackup()).toBe(null);
  });

  it('waits until the run is due', async () => {
    const { handlers } = createBackupApi();
    await call(handlers, SETTINGS, { enabled: true, intervalHours: 24 });
    const state = await handlers.readBackupState();
    // Due one interval out, so a tick before that mark does nothing.
    expect(
      await handlers.maybeRunScheduledBackup(Number(state.nextRunAt) - 1000),
    ).toBe(null);
  });

  it('leaves a run another isolate is still beating for', async () => {
    const { handlers } = createBackupApi();
    await call(handlers, SETTINGS, { enabled: true });
    const state = await handlers.readBackupState();
    const now = Number(state.nextRunAt) + 1000;
    state.runs = [
      /** @type {any} */ ({
        id: 'other',
        status: 'running',
        startedAt: new Date(now).toISOString(),
        heartbeatAt: new Date(now).toISOString(),
      }),
    ];
    await handlers.writeBackupState(state);

    expect(await handlers.maybeRunScheduledBackup(now)).toBe(null);
  });

  it('runs when one is due', async () => {
    const { handlers } = createBackupApi();
    await call(handlers, SETTINGS, { enabled: true });
    const state = await handlers.readBackupState();

    const run = await handlers.maybeRunScheduledBackup(
      Number(state.nextRunAt) + 1000,
    );
    expect(run?.trigger).toBe('scheduled');
    expect(run?.status).toBe('ok');
  });
});

describe('pruneBackups', () => {
  it('keeps only the newest snapshots', async () => {
    const { handlers, objects } = createBackupApi();
    for (const stamp of ['2026-01-01', '2026-01-02', '2026-01-03']) {
      objects.set(`snapshots/${stamp}/repo.car`, new Uint8Array([1]));
      objects.set(
        `snapshots/${stamp}/manifest.json`,
        new TextEncoder().encode(JSON.stringify({ blobs: [] })),
      );
    }

    await handlers.pruneBackups(2);

    const stamps = [...objects.keys()]
      .filter((k) => k.startsWith('snapshots/'))
      .map((k) => k.split('/')[1]);
    expect([...new Set(stamps)].sort()).toEqual(['2026-01-02', '2026-01-03']);
  });

  it('deletes blobs no kept manifest references', async () => {
    const { handlers, objects } = createBackupApi();
    objects.set(
      'snapshots/2026-01-01/manifest.json',
      new TextEncoder().encode(JSON.stringify({ blobs: ['keepme'] })),
    );
    objects.set('blobs/keepme', new Uint8Array([1]));
    objects.set('blobs/orphan', new Uint8Array([2]));

    await handlers.pruneBackups(1);

    expect(objects.has('blobs/keepme')).toBe(true);
    expect(objects.has('blobs/orphan')).toBe(false);
  });

  // Better to hold garbage than to reap a blob a live snapshot still needs.
  it('reaps nothing when a kept manifest will not parse', async () => {
    const { handlers, objects } = createBackupApi();
    objects.set(
      'snapshots/2026-01-01/manifest.json',
      new TextEncoder().encode('not json'),
    );
    objects.set('blobs/orphan', new Uint8Array([2]));

    await handlers.pruneBackups(1);

    expect(objects.has('blobs/orphan')).toBe(true);
  });

  it('reaps nothing when no kept manifest is readable at all', async () => {
    const { handlers, objects } = createBackupApi();
    objects.set('snapshots/2026-01-01/repo.car', new Uint8Array([1]));
    objects.set('blobs/orphan', new Uint8Array([2]));

    await handlers.pruneBackups(1);

    expect(objects.has('blobs/orphan')).toBe(true);
  });

  it('does nothing without a target', async () => {
    const { handlers, objects } = createBackupApi({ withTarget: false });
    await handlers.pruneBackups(1);
    expect(objects.size).toBe(0);
  });
});

describe('POST backups/run', () => {
  it('answers with the finished run', async () => {
    const { handlers, objects } = createBackupApi();
    const body = await (await call(handlers, RUN, {})).json();

    expect(body.status).toBe('ok');
    expect(body.trigger).toBe('manual');
    expect(objects.has(`${body.snapshot}/manifest.json`)).toBe(true);
    expect(objects.has(`${body.snapshot}/repo.car`)).toBe(true);
  });

  it('refuses a second run while one is going', async () => {
    const { handlers } = createBackupApi();
    const first = handlers.runBackup('manual');
    await expect(handlers.runBackup('manual')).rejects.toThrow(
      /already running/,
    );
    await first;
  });

  it('reports a stale sealed key as stale', async () => {
    const { handlers } = createBackupApi();
    await call(handlers, SETTINGS, {
      keyDerived: {
        key: base64UrlEncode(new Uint8Array(32).fill(9)),
        salt: base64UrlEncode(new Uint8Array(16).fill(1)),
        iterations: 600000,
      },
    });
    // A rotated signing key no longer matches the fingerprint that was sealed.
    const rotated = createBackupApi({
      state: await handlers.readBackupState(),
      key: new Uint8Array(32).fill(4),
    });
    const view = await rotated.handlers.publicBackupView(
      await rotated.handlers.readBackupState(),
    );

    expect(view.settings.keyBackup?.stale).toBe(true);
  });

  it('snapshots the new head when a commit lands during the walk', async () => {
    let seq = 1;
    const head = () => ({ seq, cid: `bafycommit${seq}`, rev: `rev${seq}` });
    const { handlers, objects } = createBackupApi({
      heads: head,
      buildFullRepoCar: async (cid) => {
        if (cid !== 'bafycommit2') {
          seq = 2;
          throw new Error(`Missing block: of ${cid}`);
        }
        return new Uint8Array([2]);
      },
    });
    const body = await (await call(handlers, RUN, {})).json();

    expect(body.status).toBe('ok');
    const manifest = JSON.parse(
      new TextDecoder().decode(objects.get(`${body.snapshot}/manifest.json`)),
    );
    expect(manifest.commit.cid).toBe('bafycommit2');
  });

  it('fails a snapshot whose head has not moved', async () => {
    const { handlers } = createBackupApi({
      buildFullRepoCar: async () => {
        throw new Error('Missing block: x');
      },
    });
    const body = await (await call(handlers, RUN, {})).json();

    expect(body.status).toBe('error');
  });

  it('gives up on a head that keeps moving', async () => {
    let seq = 1;
    const { handlers } = createBackupApi({
      heads: () => ({ seq, cid: `bafycommit${seq}`, rev: `rev${seq}` }),
      buildFullRepoCar: async () => {
        seq += 1;
        throw new Error('Missing block: x');
      },
    });
    const body = await (await call(handlers, RUN, {})).json();

    expect(body.status).toBe('error');
    expect(seq).toBe(4);
  });

  it('is a 400 with no target to write to', async () => {
    const { handlers } = createBackupApi({ withTarget: false });
    const response = await call(handlers, RUN, {});

    expect(response.status).toBe(400);
    expect((await response.json()).error).toMatch(/No backup target/);
  });
});