paired-devices.test.ts

16.9 KB · raw

1import { describe, expect, it, beforeEach } from "vitest";2import type { PairedDevice } from "../shared/sync-types";3import {4  clearPairedDevices,5  findPairedDeviceById,6  findPairedDeviceByPubkey,7  getLastSyncAt,8  listPairedDevices,9  markLastSyncedAt,10  isDeviceRevoked,11  listDeviceRevocations,12  mergeDeviceRevocations,13  repairPairedRevocationOverlaps,14  removePairedDeviceById,15  renamePairedDevice,16  revokeDeviceById,17  setLastSyncAt,18  setRelayUrlForAllPairedDevices,19  upsertPairedDevice,20} from "./paired-devices";2122function makeStorageStub() {23  const sync = new Map<string, unknown>();24  const local = new Map<string, unknown>();25  function area(store: Map<string, unknown>) {26    return {27      async get(key?: string | string[] | null) {28        if (key === null || key === undefined) {29          const all: Record<string, unknown> = {};30          for (const [k, v] of store) all[k] = v;31          return all;32        }33        const keys = Array.isArray(key) ? key : [key];34        const out: Record<string, unknown> = {};35        for (const k of keys) if (store.has(k)) out[k] = store.get(k);36        return out;37      },38      async set(values: Record<string, unknown>) {39        for (const [k, v] of Object.entries(values)) store.set(k, v);40      },41      async remove(key: string | string[]) {42        const keys = Array.isArray(key) ? key : [key];43        for (const k of keys) store.delete(k);44      },45    };46  }47  (globalThis as unknown as { chrome: unknown }).chrome = {48    storage: { sync: area(sync), local: area(local) },49  };50  return { sync, local };51}5253const SAMPLE: PairedDevice = {54  deviceId: "AAA-111",55  deviceName: "June's Pixel 9",56  deviceType: "android",57  longTermPublicKeyHex: "0".repeat(64),58  pairedAt: 1700000000000,59};6061describe("paired-devices registry", () => {62  beforeEach(() => makeStorageStub());6364  it("upsert + list", async () => {65    await upsertPairedDevice(SAMPLE);66    expect(await listPairedDevices()).toEqual([SAMPLE]);67  });6869  it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {70    await upsertPairedDevice(SAMPLE);71    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });72    const all = await listPairedDevices();73    expect(all).toHaveLength(1);74    expect(all[0].deviceName).toBe("Renamed Pixel");75  });7677  it("stores a local alias without changing the peer-reported name or identity", async () => {78    await upsertPairedDevice(SAMPLE);79    await renamePairedDevice(SAMPLE.deviceId, "  Travel phone  ");80    const renamed = await findPairedDeviceById(SAMPLE.deviceId);81    expect(renamed).toMatchObject({82      deviceName: SAMPLE.deviceName,83      localAlias: "Travel phone",84      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,85    });86  });8788  it("rejects blank, oversized, and unknown-device aliases", async () => {89    await upsertPairedDevice(SAMPLE);90    await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);91    await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);92    await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);93  });9495  it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {96    await upsertPairedDevice(SAMPLE);97    const newPubkey = "f".repeat(64);98    await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });99    const found = await findPairedDeviceById(SAMPLE.deviceId);100    expect(found?.longTermPublicKeyHex).toBe(newPubkey);101  });102103  it("rejects upsert with missing fields", async () => {104    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);105    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);106    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);107    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);108  });109110  it("caps oversized peer device names at the registry seam", async () => {111    await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });112    const found = await findPairedDeviceById("LONG-1");113    expect(found?.deviceName).toBe("N".repeat(64));114  });115116  it("findPairedDeviceByPubkey returns the correct device", async () => {117    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };118    await upsertPairedDevice(SAMPLE);119    await upsertPairedDevice(other);120    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);121    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();122  });123124  it("removePairedDeviceById drops the device", async () => {125    await upsertPairedDevice(SAMPLE);126    await removePairedDeviceById(SAMPLE.deviceId);127    expect(await listPairedDevices()).toEqual([]);128  });129130  it("clearPairedDevices wipes the whole registry", async () => {131    await upsertPairedDevice(SAMPLE);132    await clearPairedDevices();133    expect(await listPairedDevices()).toEqual([]);134  });135136  it("markLastSyncedAt mutates only the target device", async () => {137    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };138    await upsertPairedDevice(SAMPLE);139    await upsertPairedDevice(other);140    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);141    const found = await findPairedDeviceById(SAMPLE.deviceId);142    expect(found?.lastSyncedAt).toBe(1700001000000);143    const untouched = await findPairedDeviceById(other.deviceId);144    expect(untouched?.lastSyncedAt).toBeUndefined();145  });146147  it("serializes a concurrent pairing upsert with a sync timestamp update", async () => {148    const other: PairedDevice = {149      ...SAMPLE,150      deviceId: "BBB-222",151      longTermPublicKeyHex: "1".repeat(64),152    };153    await upsertPairedDevice(SAMPLE);154155    await Promise.all([156      upsertPairedDevice(other),157      markLastSyncedAt(SAMPLE.deviceId, 1700001000000),158    ]);159160    expect(await listPairedDevices()).toEqual([161      { ...SAMPLE, lastSyncedAt: 1700001000000 },162      other,163    ]);164  });165166  it("moves every existing pair to a new mesh relay", async () => {167    const other: PairedDevice = {168      ...SAMPLE,169      deviceId: "BBB-222",170      longTermPublicKeyHex: "1".repeat(64),171      relayUrl: "wss://old-relay.example",172    };173    await upsertPairedDevice(SAMPLE);174    await upsertPairedDevice(other);175176    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");177178    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([179      "wss://new-relay.example/base",180      "wss://new-relay.example/base",181    ]);182  });183184  it("last sync timestamp accepts a legacy string value", async () => {185    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({186      peach_last_sync_at: "1700002000000",187    });188    expect(await getLastSyncAt()).toBe(1700002000000);189  });190191  it("setLastSyncAt persists a numeric value", async () => {192    await setLastSyncAt(1700003000000);193    expect(await getLastSyncAt()).toBe(1700003000000);194  });195196  it("listPairedDevices ignores garbage entries silently", async () => {197    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({198      peach_paired_devices_v3: [199        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },200        { not: "a device" },201        null,202        42,203      ],204    });205    const devices = await listPairedDevices();206    expect(devices).toHaveLength(1);207    expect(devices[0].deviceId).toBe("real");208  });209210  describe("device revocation", () => {211    it("revokeDeviceById records the device's key and drops it from the registry", async () => {212      await upsertPairedDevice(SAMPLE);213      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {214        now: 1_700_000_000_000,215      });216217      expect(record).toMatchObject({218        deviceId: SAMPLE.deviceId,219        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,220        revokedAt: 1_700_000_000_000,221        revokedByDeviceId: "this-device",222      });223      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();224      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);225    });226227    it("revoking twice is a no-op rather than a duplicate record", async () => {228      await upsertPairedDevice(SAMPLE);229      await revokeDeviceById(SAMPLE.deviceId, "this-device");230      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();231      expect(await listDeviceRevocations()).toHaveLength(1);232    });233234    it("an existing revocation still removes a stale re-paired row", async () => {235      await upsertPairedDevice(SAMPLE);236      await revokeDeviceById(SAMPLE.deviceId, "this-device");237      // Reproduce the real corrupt overlap created by older direct-pair238      // handlers: the row returned but its terminal revocation remained.239      await upsertPairedDevice(SAMPLE);240241      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();242      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();243      expect(await listDeviceRevocations()).toHaveLength(1);244    });245246    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {247      const first = await mergeDeviceRevocations([248        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },249      ]);250      expect(first).toEqual(["a"]);251252      const second = await mergeDeviceRevocations([253        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },254        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },255      ]);256      expect(second).toEqual(["b"]);257      expect(await listDeviceRevocations()).toHaveLength(2);258    });259260    // PB-26-110: the ledger must stay bounded without letting an early flood261    // consume it permanently. A later revocation from a DIFFERENT issuer is262    // the record that matters — it has to be admitted even when the ledger is263    // already full of another issuer's junk.264    it("a later revocation from a different issuer is still admitted on a full ledger", async () => {265      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({266        deviceId: `terminal-${index}`,267        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),268        revokedAt: index + 1,269        revokedByDeviceId: "issuer-a",270      })));271272      expect(await mergeDeviceRevocations([{273        deviceId: "peer-extra",274        longTermPublicKeyHex: "f".repeat(64),275        revokedAt: 999,276        revokedByDeviceId: "issuer-b",277      }])).toEqual(["peer-extra"]);278279      const records = await listDeviceRevocations();280      expect(records).toHaveLength(500);281      // The genuine record is admitted and still blocks re-vouching.282      expect(await isDeviceRevoked("peer-extra")).toBe(true);283      // Room was made by evicting the over-represented issuer's own record,284      // not another issuer's.285      expect(records.filter((r) => r.revokedByDeviceId === "issuer-b")).toHaveLength(1);286      expect(await isDeviceRevoked("terminal-0")).toBe(false);287    });288289    it("a same-issuer local flood retains the bounded ledger", async () => {290      for (let index = 0; index < 501; index += 1) {291        await upsertPairedDevice({292          ...SAMPLE,293          deviceId: `local-${index}`,294          longTermPublicKeyHex: index.toString(16).padStart(64, "0"),295        });296        await revokeDeviceById(`local-${index}`, "issuer-a", { now: index + 1 });297      }298      const records = await listDeviceRevocations();299      expect(records).toHaveLength(500);300      expect(records.some((record) => record.deviceId === "local-0")).toBe(false);301      expect(records.at(-1)?.deviceId).toBe("local-500");302    });303304    it("a different issuer local revoke preserves one of its genuine records", async () => {305      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({306        deviceId: `terminal-${index}`,307        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),308        revokedAt: index + 1,309        revokedByDeviceId: index === 0 ? "issuer-a" : "issuer-b",310      })));311      await upsertPairedDevice({ ...SAMPLE, deviceId: "local-new", longTermPublicKeyHex: "f".repeat(64) });312      await revokeDeviceById("local-new", "issuer-a", { now: 501 });313      const records = await listDeviceRevocations();314      expect(records).toHaveLength(500);315      expect(records.some((record) => record.deviceId === "terminal-0")).toBe(true);316      expect(records.some((record) => record.deviceId === "local-new")).toBe(true);317    });318319    it("a peer flood cannot evict an older revocation from another issuer", async () => {320      await mergeDeviceRevocations([{321        deviceId: "genuine",322        longTermPublicKeyHex: "a".repeat(64),323        revokedAt: 1,324        revokedByDeviceId: "honest-issuer",325      }]);326      const flood = Array.from({ length: 600 }, (_, index) => ({327        deviceId: `junk-${index}`,328        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),329        revokedAt: index + 2,330        revokedByDeviceId: "flooding-issuer",331      }));332      const admitted = await mergeDeviceRevocations(flood);333      expect(admitted).toHaveLength(600);334      const records = await listDeviceRevocations();335      expect(records).toHaveLength(500);336      expect(await isDeviceRevoked("genuine")).toBe(true);337      // The flooder is the over-represented issuer, so its own oldest records338      // are what get dropped — the newest junk survives, the oldest does not.339      expect(await isDeviceRevoked("junk-599")).toBe(true);340      expect(await isDeviceRevoked("junk-0")).toBe(false);341    });342343    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {344      await mergeDeviceRevocations([345        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },346      ]);347      await mergeDeviceRevocations([348        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },349      ]);350      const records = await listDeviceRevocations();351      expect(records).toHaveLength(1);352      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });353    });354355    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {356      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;357      await mergeDeviceRevocations([358        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },359      ]);360      const [record] = await listDeviceRevocations();361      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);362    });363364    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {365      const now = Date.now();366      await mergeDeviceRevocations([{367        deviceId: SAMPLE.deviceId,368        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,369        revokedAt: now - 1_000,370        revokedByDeviceId: "this-device",371      }]);372      await upsertPairedDevice({373        ...SAMPLE,374        pairedAt: now,375        trustSource: "direct",376      });377378      expect(await repairPairedRevocationOverlaps()).toEqual({379        revocationsCleared: 1,380        pairedRowsRemoved: 0,381      });382      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();383      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);384    });385386    it("startup repair removes a direct pair older than its revocation", async () => {387      const now = Date.now();388      await upsertPairedDevice({389        ...SAMPLE,390        pairedAt: now - 2_000,391        trustSource: "direct",392      });393      await mergeDeviceRevocations([{394        deviceId: SAMPLE.deviceId,395        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,396        revokedAt: now - 1_000,397        revokedByDeviceId: "this-device",398      }]);399400      expect(await repairPairedRevocationOverlaps()).toEqual({401        revocationsCleared: 0,402        pairedRowsRemoved: 1,403      });404      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();405      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);406    });407408    it("startup repair never lets a newer vouched pair lift a revocation", async () => {409      const now = Date.now();410      await mergeDeviceRevocations([{411        deviceId: SAMPLE.deviceId,412        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,413        revokedAt: now - 1_000,414        revokedByDeviceId: "this-device",415      }]);416      await upsertPairedDevice({417        ...SAMPLE,418        pairedAt: now,419        trustSource: "vouched",420      });421422      expect(await repairPairedRevocationOverlaps()).toEqual({423        revocationsCleared: 0,424        pairedRowsRemoved: 1,425      });426      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();427      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);428    });429  });430});