paired-devices.test.ts

12.6 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("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {242      await mergeDeviceRevocations([243        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },244      ]);245      await mergeDeviceRevocations([246        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },247      ]);248      const records = await listDeviceRevocations();249      expect(records).toHaveLength(1);250      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });251    });252253    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {254      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;255      await mergeDeviceRevocations([256        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },257      ]);258      const [record] = await listDeviceRevocations();259      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);260    });261262    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {263      const now = Date.now();264      await mergeDeviceRevocations([{265        deviceId: SAMPLE.deviceId,266        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,267        revokedAt: now - 1_000,268        revokedByDeviceId: "this-device",269      }]);270      await upsertPairedDevice({271        ...SAMPLE,272        pairedAt: now,273        trustSource: "direct",274      });275276      expect(await repairPairedRevocationOverlaps()).toEqual({277        revocationsCleared: 1,278        pairedRowsRemoved: 0,279      });280      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();281      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);282    });283284    it("startup repair removes a direct pair older than its revocation", async () => {285      const now = Date.now();286      await upsertPairedDevice({287        ...SAMPLE,288        pairedAt: now - 2_000,289        trustSource: "direct",290      });291      await mergeDeviceRevocations([{292        deviceId: SAMPLE.deviceId,293        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,294        revokedAt: now - 1_000,295        revokedByDeviceId: "this-device",296      }]);297298      expect(await repairPairedRevocationOverlaps()).toEqual({299        revocationsCleared: 0,300        pairedRowsRemoved: 1,301      });302      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();303      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);304    });305306    it("startup repair never lets a newer vouched pair lift a revocation", async () => {307      const now = Date.now();308      await mergeDeviceRevocations([{309        deviceId: SAMPLE.deviceId,310        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,311        revokedAt: now - 1_000,312        revokedByDeviceId: "this-device",313      }]);314      await upsertPairedDevice({315        ...SAMPLE,316        pairedAt: now,317        trustSource: "vouched",318      });319320      expect(await repairPairedRevocationOverlaps()).toEqual({321        revocationsCleared: 0,322        pairedRowsRemoved: 1,323      });324      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();325      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);326    });327  });328});