paired-devices.test.ts

15.5 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("moves every existing pair to a new mesh relay", async () => {148    const other: PairedDevice = {149      ...SAMPLE,150      deviceId: "BBB-222",151      longTermPublicKeyHex: "1".repeat(64),152      relayUrl: "wss://old-relay.example",153    };154    await upsertPairedDevice(SAMPLE);155    await upsertPairedDevice(other);156157    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");158159    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([160      "wss://new-relay.example/base",161      "wss://new-relay.example/base",162    ]);163  });164165  it("last sync timestamp accepts a legacy string value", async () => {166    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({167      peach_last_sync_at: "1700002000000",168    });169    expect(await getLastSyncAt()).toBe(1700002000000);170  });171172  it("setLastSyncAt persists a numeric value", async () => {173    await setLastSyncAt(1700003000000);174    expect(await getLastSyncAt()).toBe(1700003000000);175  });176177  it("listPairedDevices ignores garbage entries silently", async () => {178    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({179      peach_paired_devices_v3: [180        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },181        { not: "a device" },182        null,183        42,184      ],185    });186    const devices = await listPairedDevices();187    expect(devices).toHaveLength(1);188    expect(devices[0].deviceId).toBe("real");189  });190191  describe("device revocation", () => {192    it("revokeDeviceById records the device's key and drops it from the registry", async () => {193      await upsertPairedDevice(SAMPLE);194      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {195        now: 1_700_000_000_000,196      });197198      expect(record).toMatchObject({199        deviceId: SAMPLE.deviceId,200        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,201        revokedAt: 1_700_000_000_000,202        revokedByDeviceId: "this-device",203      });204      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();205      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);206    });207208    it("revoking twice is a no-op rather than a duplicate record", async () => {209      await upsertPairedDevice(SAMPLE);210      await revokeDeviceById(SAMPLE.deviceId, "this-device");211      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();212      expect(await listDeviceRevocations()).toHaveLength(1);213    });214215    it("an existing revocation still removes a stale re-paired row", async () => {216      await upsertPairedDevice(SAMPLE);217      await revokeDeviceById(SAMPLE.deviceId, "this-device");218      // Reproduce the real corrupt overlap created by older direct-pair219      // handlers: the row returned but its terminal revocation remained.220      await upsertPairedDevice(SAMPLE);221222      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();223      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();224      expect(await listDeviceRevocations()).toHaveLength(1);225    });226227    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {228      const first = await mergeDeviceRevocations([229        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },230      ]);231      expect(first).toEqual(["a"]);232233      const second = await mergeDeviceRevocations([234        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },235        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },236      ]);237      expect(second).toEqual(["b"]);238      expect(await listDeviceRevocations()).toHaveLength(2);239    });240241    it("peer input cannot evict any record when the ledger is full", async () => {242      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({243        deviceId: `terminal-${index}`,244        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),245        revokedAt: index + 1,246        revokedByDeviceId: "issuer-a",247      })));248      expect(await mergeDeviceRevocations([{249        deviceId: "peer-extra",250        longTermPublicKeyHex: "f".repeat(64),251        revokedAt: 999,252        revokedByDeviceId: "issuer-b",253      }])).toEqual([]);254      expect(await listDeviceRevocations()).toHaveLength(500);255      expect(await isDeviceRevoked("terminal-0")).toBe(true);256    });257258    it("a same-issuer local flood retains the bounded ledger", async () => {259      for (let index = 0; index < 501; index += 1) {260        await upsertPairedDevice({261          ...SAMPLE,262          deviceId: `local-${index}`,263          longTermPublicKeyHex: index.toString(16).padStart(64, "0"),264        });265        await revokeDeviceById(`local-${index}`, "issuer-a", { now: index + 1 });266      }267      const records = await listDeviceRevocations();268      expect(records).toHaveLength(500);269      expect(records.some((record) => record.deviceId === "local-0")).toBe(false);270      expect(records.at(-1)?.deviceId).toBe("local-500");271    });272273    it("a different issuer local revoke preserves one of its genuine records", async () => {274      await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({275        deviceId: `terminal-${index}`,276        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),277        revokedAt: index + 1,278        revokedByDeviceId: index === 0 ? "issuer-a" : "issuer-b",279      })));280      await upsertPairedDevice({ ...SAMPLE, deviceId: "local-new", longTermPublicKeyHex: "f".repeat(64) });281      await revokeDeviceById("local-new", "issuer-a", { now: 501 });282      const records = await listDeviceRevocations();283      expect(records).toHaveLength(500);284      expect(records.some((record) => record.deviceId === "terminal-0")).toBe(true);285      expect(records.some((record) => record.deviceId === "local-new")).toBe(true);286    });287288    it("a peer flood cannot evict an older revocation from another issuer", async () => {289      await mergeDeviceRevocations([{290        deviceId: "genuine",291        longTermPublicKeyHex: "a".repeat(64),292        revokedAt: 1,293        revokedByDeviceId: "honest-issuer",294      }]);295      const flood = Array.from({ length: 600 }, (_, index) => ({296        deviceId: `junk-${index}`,297        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),298        revokedAt: index + 2,299        revokedByDeviceId: "flooding-issuer",300      }));301      const admitted = await mergeDeviceRevocations(flood);302      expect(admitted).toHaveLength(499);303      expect(await listDeviceRevocations()).toHaveLength(500);304      expect(await isDeviceRevoked("genuine")).toBe(true);305      expect(await isDeviceRevoked("junk-599")).toBe(false);306    });307308    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {309      await mergeDeviceRevocations([310        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },311      ]);312      await mergeDeviceRevocations([313        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },314      ]);315      const records = await listDeviceRevocations();316      expect(records).toHaveLength(1);317      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });318    });319320    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {321      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;322      await mergeDeviceRevocations([323        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },324      ]);325      const [record] = await listDeviceRevocations();326      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);327    });328329    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {330      const now = Date.now();331      await mergeDeviceRevocations([{332        deviceId: SAMPLE.deviceId,333        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,334        revokedAt: now - 1_000,335        revokedByDeviceId: "this-device",336      }]);337      await upsertPairedDevice({338        ...SAMPLE,339        pairedAt: now,340        trustSource: "direct",341      });342343      expect(await repairPairedRevocationOverlaps()).toEqual({344        revocationsCleared: 1,345        pairedRowsRemoved: 0,346      });347      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();348      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);349    });350351    it("startup repair removes a direct pair older than its revocation", async () => {352      const now = Date.now();353      await upsertPairedDevice({354        ...SAMPLE,355        pairedAt: now - 2_000,356        trustSource: "direct",357      });358      await mergeDeviceRevocations([{359        deviceId: SAMPLE.deviceId,360        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,361        revokedAt: now - 1_000,362        revokedByDeviceId: "this-device",363      }]);364365      expect(await repairPairedRevocationOverlaps()).toEqual({366        revocationsCleared: 0,367        pairedRowsRemoved: 1,368      });369      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();370      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);371    });372373    it("startup repair never lets a newer vouched pair lift a revocation", async () => {374      const now = Date.now();375      await mergeDeviceRevocations([{376        deviceId: SAMPLE.deviceId,377        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,378        revokedAt: now - 1_000,379        revokedByDeviceId: "this-device",380      }]);381      await upsertPairedDevice({382        ...SAMPLE,383        pairedAt: now,384        trustSource: "vouched",385      });386387      expect(await repairPairedRevocationOverlaps()).toEqual({388        revocationsCleared: 0,389        pairedRowsRemoved: 1,390      });391      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();392      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);393    });394  });395});