paired-devices.test.ts

13.0 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  clearDeviceRevocation,11  isDeviceRevoked,12  listDeviceRevocations,13  mergeDeviceRevocations,14  repairPairedRevocationOverlaps,15  removePairedDeviceById,16  renamePairedDevice,17  revokeDeviceById,18  setLastSyncAt,19  setRelayUrlForAllPairedDevices,20  upsertPairedDevice,21} from "./paired-devices";2223function makeStorageStub() {24  const sync = new Map<string, unknown>();25  const local = new Map<string, unknown>();26  function area(store: Map<string, unknown>) {27    return {28      async get(key?: string | string[] | null) {29        if (key === null || key === undefined) {30          const all: Record<string, unknown> = {};31          for (const [k, v] of store) all[k] = v;32          return all;33        }34        const keys = Array.isArray(key) ? key : [key];35        const out: Record<string, unknown> = {};36        for (const k of keys) if (store.has(k)) out[k] = store.get(k);37        return out;38      },39      async set(values: Record<string, unknown>) {40        for (const [k, v] of Object.entries(values)) store.set(k, v);41      },42      async remove(key: string | string[]) {43        const keys = Array.isArray(key) ? key : [key];44        for (const k of keys) store.delete(k);45      },46    };47  }48  (globalThis as unknown as { chrome: unknown }).chrome = {49    storage: { sync: area(sync), local: area(local) },50  };51  return { sync, local };52}5354const SAMPLE: PairedDevice = {55  deviceId: "AAA-111",56  deviceName: "June's Pixel 9",57  deviceType: "android",58  longTermPublicKeyHex: "0".repeat(64),59  pairedAt: 1700000000000,60};6162describe("paired-devices registry", () => {63  beforeEach(() => makeStorageStub());6465  it("upsert + list", async () => {66    await upsertPairedDevice(SAMPLE);67    expect(await listPairedDevices()).toEqual([SAMPLE]);68  });6970  it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {71    await upsertPairedDevice(SAMPLE);72    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });73    const all = await listPairedDevices();74    expect(all).toHaveLength(1);75    expect(all[0].deviceName).toBe("Renamed Pixel");76  });7778  it("stores a local alias without changing the peer-reported name or identity", async () => {79    await upsertPairedDevice(SAMPLE);80    await renamePairedDevice(SAMPLE.deviceId, "  Travel phone  ");81    const renamed = await findPairedDeviceById(SAMPLE.deviceId);82    expect(renamed).toMatchObject({83      deviceName: SAMPLE.deviceName,84      localAlias: "Travel phone",85      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,86    });87  });8889  it("rejects blank, oversized, and unknown-device aliases", async () => {90    await upsertPairedDevice(SAMPLE);91    await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);92    await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);93    await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);94  });9596  it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {97    await upsertPairedDevice(SAMPLE);98    const newPubkey = "f".repeat(64);99    await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });100    const found = await findPairedDeviceById(SAMPLE.deviceId);101    expect(found?.longTermPublicKeyHex).toBe(newPubkey);102  });103104  it("rejects upsert with missing fields", async () => {105    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);106    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);107    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);108    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);109  });110111  it("caps oversized peer device names at the registry seam", async () => {112    await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });113    const found = await findPairedDeviceById("LONG-1");114    expect(found?.deviceName).toBe("N".repeat(64));115  });116117  it("findPairedDeviceByPubkey returns the correct device", async () => {118    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };119    await upsertPairedDevice(SAMPLE);120    await upsertPairedDevice(other);121    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);122    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();123  });124125  it("removePairedDeviceById drops the device", async () => {126    await upsertPairedDevice(SAMPLE);127    await removePairedDeviceById(SAMPLE.deviceId);128    expect(await listPairedDevices()).toEqual([]);129  });130131  it("clearPairedDevices wipes the whole registry", async () => {132    await upsertPairedDevice(SAMPLE);133    await clearPairedDevices();134    expect(await listPairedDevices()).toEqual([]);135  });136137  it("markLastSyncedAt mutates only the target device", async () => {138    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };139    await upsertPairedDevice(SAMPLE);140    await upsertPairedDevice(other);141    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);142    const found = await findPairedDeviceById(SAMPLE.deviceId);143    expect(found?.lastSyncedAt).toBe(1700001000000);144    const untouched = await findPairedDeviceById(other.deviceId);145    expect(untouched?.lastSyncedAt).toBeUndefined();146  });147148  it("moves every existing pair to a new mesh relay", async () => {149    const other: PairedDevice = {150      ...SAMPLE,151      deviceId: "BBB-222",152      longTermPublicKeyHex: "1".repeat(64),153      relayUrl: "wss://old-relay.example",154    };155    await upsertPairedDevice(SAMPLE);156    await upsertPairedDevice(other);157158    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");159160    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([161      "wss://new-relay.example/base",162      "wss://new-relay.example/base",163    ]);164  });165166  it("last sync timestamp accepts a legacy string value", async () => {167    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({168      peach_last_sync_at: "1700002000000",169    });170    expect(await getLastSyncAt()).toBe(1700002000000);171  });172173  it("setLastSyncAt persists a numeric value", async () => {174    await setLastSyncAt(1700003000000);175    expect(await getLastSyncAt()).toBe(1700003000000);176  });177178  it("listPairedDevices ignores garbage entries silently", async () => {179    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({180      peach_paired_devices_v3: [181        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },182        { not: "a device" },183        null,184        42,185      ],186    });187    const devices = await listPairedDevices();188    expect(devices).toHaveLength(1);189    expect(devices[0].deviceId).toBe("real");190  });191192  describe("device revocation", () => {193    it("revokeDeviceById records the device's key and drops it from the registry", async () => {194      await upsertPairedDevice(SAMPLE);195      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {196        now: 1_700_000_000_000,197      });198199      expect(record).toMatchObject({200        deviceId: SAMPLE.deviceId,201        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,202        revokedAt: 1_700_000_000_000,203        revokedByDeviceId: "this-device",204      });205      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();206      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);207    });208209    it("revoking twice is a no-op rather than a duplicate record", async () => {210      await upsertPairedDevice(SAMPLE);211      await revokeDeviceById(SAMPLE.deviceId, "this-device");212      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();213      expect(await listDeviceRevocations()).toHaveLength(1);214    });215216    it("an existing revocation still removes a stale re-paired row", async () => {217      await upsertPairedDevice(SAMPLE);218      await revokeDeviceById(SAMPLE.deviceId, "this-device");219      // Reproduce the real corrupt overlap created by older direct-pair220      // handlers: the row returned but its terminal revocation remained.221      await upsertPairedDevice(SAMPLE);222223      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();224      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();225      expect(await listDeviceRevocations()).toHaveLength(1);226    });227228    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {229      const first = await mergeDeviceRevocations([230        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },231      ]);232      expect(first).toEqual(["a"]);233234      const second = await mergeDeviceRevocations([235        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },236        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },237      ]);238      expect(second).toEqual(["b"]);239      expect(await listDeviceRevocations()).toHaveLength(2);240    });241242    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {243      await mergeDeviceRevocations([244        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },245      ]);246      await mergeDeviceRevocations([247        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },248      ]);249      const records = await listDeviceRevocations();250      expect(records).toHaveLength(1);251      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });252    });253254    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {255      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;256      await mergeDeviceRevocations([257        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },258      ]);259      const [record] = await listDeviceRevocations();260      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);261    });262263    it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {264      await upsertPairedDevice(SAMPLE);265      await revokeDeviceById(SAMPLE.deviceId, "this-device");266      await clearDeviceRevocation(SAMPLE.deviceId);267268      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);269      expect(await listDeviceRevocations()).toHaveLength(0);270    });271272    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {273      const now = Date.now();274      await mergeDeviceRevocations([{275        deviceId: SAMPLE.deviceId,276        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,277        revokedAt: now - 1_000,278        revokedByDeviceId: "this-device",279      }]);280      await upsertPairedDevice({281        ...SAMPLE,282        pairedAt: now,283        trustSource: "direct",284      });285286      expect(await repairPairedRevocationOverlaps()).toEqual({287        revocationsCleared: 1,288        pairedRowsRemoved: 0,289      });290      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();291      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);292    });293294    it("startup repair removes a direct pair older than its revocation", async () => {295      const now = Date.now();296      await upsertPairedDevice({297        ...SAMPLE,298        pairedAt: now - 2_000,299        trustSource: "direct",300      });301      await mergeDeviceRevocations([{302        deviceId: SAMPLE.deviceId,303        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,304        revokedAt: now - 1_000,305        revokedByDeviceId: "this-device",306      }]);307308      expect(await repairPairedRevocationOverlaps()).toEqual({309        revocationsCleared: 0,310        pairedRowsRemoved: 1,311      });312      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();313      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);314    });315316    it("startup repair never lets a newer vouched pair lift a revocation", async () => {317      const now = Date.now();318      await mergeDeviceRevocations([{319        deviceId: SAMPLE.deviceId,320        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,321        revokedAt: now - 1_000,322        revokedByDeviceId: "this-device",323      }]);324      await upsertPairedDevice({325        ...SAMPLE,326        pairedAt: now,327        trustSource: "vouched",328      });329330      expect(await repairPairedRevocationOverlaps()).toEqual({331        revocationsCleared: 0,332        pairedRowsRemoved: 1,333      });334      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();335      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);336    });337  });338});