paired-devices.test.ts

13.4 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 peer flood cannot evict an older revocation from another issuer", async () => {242      await mergeDeviceRevocations([{243        deviceId: "genuine",244        longTermPublicKeyHex: "a".repeat(64),245        revokedAt: 1,246        revokedByDeviceId: "honest-issuer",247      }]);248      const flood = Array.from({ length: 600 }, (_, index) => ({249        deviceId: `junk-${index}`,250        longTermPublicKeyHex: index.toString(16).padStart(64, "0"),251        revokedAt: index + 2,252        revokedByDeviceId: "flooding-issuer",253      }));254      const admitted = await mergeDeviceRevocations(flood);255      expect(admitted).toHaveLength(499);256      expect(await listDeviceRevocations()).toHaveLength(500);257      expect(await isDeviceRevoked("genuine")).toBe(true);258      expect(await isDeviceRevoked("junk-599")).toBe(false);259    });260261    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {262      await mergeDeviceRevocations([263        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },264      ]);265      await mergeDeviceRevocations([266        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },267      ]);268      const records = await listDeviceRevocations();269      expect(records).toHaveLength(1);270      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });271    });272273    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {274      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;275      await mergeDeviceRevocations([276        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },277      ]);278      const [record] = await listDeviceRevocations();279      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);280    });281282    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {283      const now = Date.now();284      await mergeDeviceRevocations([{285        deviceId: SAMPLE.deviceId,286        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,287        revokedAt: now - 1_000,288        revokedByDeviceId: "this-device",289      }]);290      await upsertPairedDevice({291        ...SAMPLE,292        pairedAt: now,293        trustSource: "direct",294      });295296      expect(await repairPairedRevocationOverlaps()).toEqual({297        revocationsCleared: 1,298        pairedRowsRemoved: 0,299      });300      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();301      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);302    });303304    it("startup repair removes a direct pair older than its revocation", async () => {305      const now = Date.now();306      await upsertPairedDevice({307        ...SAMPLE,308        pairedAt: now - 2_000,309        trustSource: "direct",310      });311      await mergeDeviceRevocations([{312        deviceId: SAMPLE.deviceId,313        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,314        revokedAt: now - 1_000,315        revokedByDeviceId: "this-device",316      }]);317318      expect(await repairPairedRevocationOverlaps()).toEqual({319        revocationsCleared: 0,320        pairedRowsRemoved: 1,321      });322      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();323      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);324    });325326    it("startup repair never lets a newer vouched pair lift a revocation", async () => {327      const now = Date.now();328      await mergeDeviceRevocations([{329        deviceId: SAMPLE.deviceId,330        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,331        revokedAt: now - 1_000,332        revokedByDeviceId: "this-device",333      }]);334      await upsertPairedDevice({335        ...SAMPLE,336        pairedAt: now,337        trustSource: "vouched",338      });339340      expect(await repairPairedRevocationOverlaps()).toEqual({341        revocationsCleared: 0,342        pairedRowsRemoved: 1,343      });344      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();345      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);346    });347  });348});