paired-devices.test.ts

12.7 KB · raw

1import { describe, expect, it, beforeEach } from "vitest";2import type { PairedDevice } from "../shared/sync-types";3import {4  clearLastKnownIp,5  clearPairedDevices,6  findPairedDeviceById,7  findPairedDeviceByPubkey,8  getLastKnownIp,9  getLastSyncAt,10  listPairedDevices,11  markLastSyncedAt,12  clearDeviceRevocation,13  isDeviceRevoked,14  listDeviceRevocations,15  mergeDeviceRevocations,16  repairPairedRevocationOverlaps,17  removePairedDeviceById,18  revokeDeviceById,19  setLastKnownIp,20  setLastSyncAt,21  setRelayUrlForAllPairedDevices,22  upsertPairedDevice,23} from "./paired-devices";2425function makeStorageStub() {26  const sync = new Map<string, unknown>();27  const local = new Map<string, unknown>();28  function area(store: Map<string, unknown>) {29    return {30      async get(key?: string | string[] | null) {31        if (key === null || key === undefined) {32          const all: Record<string, unknown> = {};33          for (const [k, v] of store) all[k] = v;34          return all;35        }36        const keys = Array.isArray(key) ? key : [key];37        const out: Record<string, unknown> = {};38        for (const k of keys) if (store.has(k)) out[k] = store.get(k);39        return out;40      },41      async set(values: Record<string, unknown>) {42        for (const [k, v] of Object.entries(values)) store.set(k, v);43      },44      async remove(key: string | string[]) {45        const keys = Array.isArray(key) ? key : [key];46        for (const k of keys) store.delete(k);47      },48    };49  }50  (globalThis as unknown as { chrome: unknown }).chrome = {51    storage: { sync: area(sync), local: area(local) },52  };53  return { sync, local };54}5556const SAMPLE: PairedDevice = {57  deviceId: "AAA-111",58  deviceName: "June's Pixel 9",59  deviceType: "android",60  longTermPublicKeyHex: "0".repeat(64),61  pairedAt: 1700000000000,62};6364describe("paired-devices registry", () => {65  beforeEach(() => makeStorageStub());6667  it("upsert + list", async () => {68    await upsertPairedDevice(SAMPLE);69    expect(await listPairedDevices()).toEqual([SAMPLE]);70  });7172  it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {73    await upsertPairedDevice(SAMPLE);74    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });75    const all = await listPairedDevices();76    expect(all).toHaveLength(1);77    expect(all[0].deviceName).toBe("Renamed Pixel");78  });7980  it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {81    await upsertPairedDevice(SAMPLE);82    const newPubkey = "f".repeat(64);83    await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });84    const found = await findPairedDeviceById(SAMPLE.deviceId);85    expect(found?.longTermPublicKeyHex).toBe(newPubkey);86  });8788  it("rejects upsert with missing fields", async () => {89    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);90    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);91    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);92    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);93  });9495  it("caps oversized peer device names at the registry seam", async () => {96    await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });97    const found = await findPairedDeviceById("LONG-1");98    expect(found?.deviceName).toBe("N".repeat(64));99  });100101  it("findPairedDeviceByPubkey returns the correct device", async () => {102    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };103    await upsertPairedDevice(SAMPLE);104    await upsertPairedDevice(other);105    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);106    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();107  });108109  it("removePairedDeviceById drops the device and its IP cache", async () => {110    await upsertPairedDevice(SAMPLE);111    await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");112    await removePairedDeviceById(SAMPLE.deviceId);113    expect(await listPairedDevices()).toEqual([]);114    expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();115  });116117  it("clearPairedDevices wipes the whole registry", async () => {118    await upsertPairedDevice(SAMPLE);119    await clearPairedDevices();120    expect(await listPairedDevices()).toEqual([]);121  });122123  it("markLastSyncedAt mutates only the target device", async () => {124    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };125    await upsertPairedDevice(SAMPLE);126    await upsertPairedDevice(other);127    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);128    const found = await findPairedDeviceById(SAMPLE.deviceId);129    expect(found?.lastSyncedAt).toBe(1700001000000);130    const untouched = await findPairedDeviceById(other.deviceId);131    expect(untouched?.lastSyncedAt).toBeUndefined();132  });133134  it("moves every existing pair to a new mesh relay", async () => {135    const other: PairedDevice = {136      ...SAMPLE,137      deviceId: "BBB-222",138      longTermPublicKeyHex: "1".repeat(64),139      relayUrl: "wss://old-relay.example",140    };141    await upsertPairedDevice(SAMPLE);142    await upsertPairedDevice(other);143144    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");145146    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([147      "wss://new-relay.example/base",148      "wss://new-relay.example/base",149    ]);150  });151152  it("getLastKnownIp / setLastKnownIp round-trip", async () => {153    await setLastKnownIp("ab".repeat(32), "10.0.0.5");154    expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");155    await clearLastKnownIp("ab".repeat(32));156    expect(await getLastKnownIp("ab".repeat(32))).toBeNull();157  });158159  it("last sync timestamp accepts a legacy string value", async () => {160    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({161      peach_last_sync_at: "1700002000000",162    });163    expect(await getLastSyncAt()).toBe(1700002000000);164  });165166  it("setLastSyncAt persists a numeric value", async () => {167    await setLastSyncAt(1700003000000);168    expect(await getLastSyncAt()).toBe(1700003000000);169  });170171  it("listPairedDevices ignores garbage entries silently", async () => {172    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({173      peach_paired_devices_v3: [174        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },175        { not: "a device" },176        null,177        42,178      ],179    });180    const devices = await listPairedDevices();181    expect(devices).toHaveLength(1);182    expect(devices[0].deviceId).toBe("real");183  });184185  describe("device revocation", () => {186    it("revokeDeviceById records the device's key and drops it from the registry", async () => {187      await upsertPairedDevice(SAMPLE);188      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {189        now: 1_700_000_000_000,190      });191192      expect(record).toMatchObject({193        deviceId: SAMPLE.deviceId,194        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,195        revokedAt: 1_700_000_000_000,196        revokedByDeviceId: "this-device",197      });198      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();199      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);200    });201202    it("revoking twice is a no-op rather than a duplicate record", async () => {203      await upsertPairedDevice(SAMPLE);204      await revokeDeviceById(SAMPLE.deviceId, "this-device");205      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();206      expect(await listDeviceRevocations()).toHaveLength(1);207    });208209    it("an existing revocation still removes a stale re-paired row", async () => {210      await upsertPairedDevice(SAMPLE);211      await revokeDeviceById(SAMPLE.deviceId, "this-device");212      // Reproduce the real corrupt overlap created by older direct-pair213      // handlers: the row returned but its terminal revocation remained.214      await upsertPairedDevice(SAMPLE);215216      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();217      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();218      expect(await listDeviceRevocations()).toHaveLength(1);219    });220221    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {222      const first = await mergeDeviceRevocations([223        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },224      ]);225      expect(first).toEqual(["a"]);226227      const second = await mergeDeviceRevocations([228        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },229        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },230      ]);231      expect(second).toEqual(["b"]);232      expect(await listDeviceRevocations()).toHaveLength(2);233    });234235    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {236      await mergeDeviceRevocations([237        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },238      ]);239      await mergeDeviceRevocations([240        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },241      ]);242      const records = await listDeviceRevocations();243      expect(records).toHaveLength(1);244      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });245    });246247    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {248      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;249      await mergeDeviceRevocations([250        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },251      ]);252      const [record] = await listDeviceRevocations();253      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);254    });255256    it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {257      await upsertPairedDevice(SAMPLE);258      await revokeDeviceById(SAMPLE.deviceId, "this-device");259      await clearDeviceRevocation(SAMPLE.deviceId);260261      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);262      expect(await listDeviceRevocations()).toHaveLength(0);263    });264265    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {266      const now = Date.now();267      await mergeDeviceRevocations([{268        deviceId: SAMPLE.deviceId,269        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,270        revokedAt: now - 1_000,271        revokedByDeviceId: "this-device",272      }]);273      await upsertPairedDevice({274        ...SAMPLE,275        pairedAt: now,276        trustSource: "direct",277      });278279      expect(await repairPairedRevocationOverlaps()).toEqual({280        revocationsCleared: 1,281        pairedRowsRemoved: 0,282      });283      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();284      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);285    });286287    it("startup repair removes a direct pair older than its revocation", async () => {288      const now = Date.now();289      await upsertPairedDevice({290        ...SAMPLE,291        pairedAt: now - 2_000,292        trustSource: "direct",293      });294      await mergeDeviceRevocations([{295        deviceId: SAMPLE.deviceId,296        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,297        revokedAt: now - 1_000,298        revokedByDeviceId: "this-device",299      }]);300301      expect(await repairPairedRevocationOverlaps()).toEqual({302        revocationsCleared: 0,303        pairedRowsRemoved: 1,304      });305      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();306      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);307    });308309    it("startup repair never lets a newer vouched pair lift a revocation", async () => {310      const now = Date.now();311      await mergeDeviceRevocations([{312        deviceId: SAMPLE.deviceId,313        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,314        revokedAt: now - 1_000,315        revokedByDeviceId: "this-device",316      }]);317      await upsertPairedDevice({318        ...SAMPLE,319        pairedAt: now,320        trustSource: "vouched",321      });322323      expect(await repairPairedRevocationOverlaps()).toEqual({324        revocationsCleared: 0,325        pairedRowsRemoved: 1,326      });327      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();328      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);329    });330  });331});