paired-devices.test.ts

13.5 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  renamePairedDevice,19  revokeDeviceById,20  setLastKnownIp,21  setLastSyncAt,22  setRelayUrlForAllPairedDevices,23  upsertPairedDevice,24} from "./paired-devices";2526function makeStorageStub() {27  const sync = new Map<string, unknown>();28  const local = new Map<string, unknown>();29  function area(store: Map<string, unknown>) {30    return {31      async get(key?: string | string[] | null) {32        if (key === null || key === undefined) {33          const all: Record<string, unknown> = {};34          for (const [k, v] of store) all[k] = v;35          return all;36        }37        const keys = Array.isArray(key) ? key : [key];38        const out: Record<string, unknown> = {};39        for (const k of keys) if (store.has(k)) out[k] = store.get(k);40        return out;41      },42      async set(values: Record<string, unknown>) {43        for (const [k, v] of Object.entries(values)) store.set(k, v);44      },45      async remove(key: string | string[]) {46        const keys = Array.isArray(key) ? key : [key];47        for (const k of keys) store.delete(k);48      },49    };50  }51  (globalThis as unknown as { chrome: unknown }).chrome = {52    storage: { sync: area(sync), local: area(local) },53  };54  return { sync, local };55}5657const SAMPLE: PairedDevice = {58  deviceId: "AAA-111",59  deviceName: "June's Pixel 9",60  deviceType: "android",61  longTermPublicKeyHex: "0".repeat(64),62  pairedAt: 1700000000000,63};6465describe("paired-devices registry", () => {66  beforeEach(() => makeStorageStub());6768  it("upsert + list", async () => {69    await upsertPairedDevice(SAMPLE);70    expect(await listPairedDevices()).toEqual([SAMPLE]);71  });7273  it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {74    await upsertPairedDevice(SAMPLE);75    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });76    const all = await listPairedDevices();77    expect(all).toHaveLength(1);78    expect(all[0].deviceName).toBe("Renamed Pixel");79  });8081  it("stores a local alias without changing the peer-reported name or identity", async () => {82    await upsertPairedDevice(SAMPLE);83    await renamePairedDevice(SAMPLE.deviceId, "  Travel phone  ");84    const renamed = await findPairedDeviceById(SAMPLE.deviceId);85    expect(renamed).toMatchObject({86      deviceName: SAMPLE.deviceName,87      localAlias: "Travel phone",88      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,89    });90  });9192  it("rejects blank, oversized, and unknown-device aliases", async () => {93    await upsertPairedDevice(SAMPLE);94    await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);95    await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);96    await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);97  });9899  it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {100    await upsertPairedDevice(SAMPLE);101    const newPubkey = "f".repeat(64);102    await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });103    const found = await findPairedDeviceById(SAMPLE.deviceId);104    expect(found?.longTermPublicKeyHex).toBe(newPubkey);105  });106107  it("rejects upsert with missing fields", async () => {108    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);109    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);110    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);111    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);112  });113114  it("caps oversized peer device names at the registry seam", async () => {115    await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });116    const found = await findPairedDeviceById("LONG-1");117    expect(found?.deviceName).toBe("N".repeat(64));118  });119120  it("findPairedDeviceByPubkey returns the correct device", async () => {121    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };122    await upsertPairedDevice(SAMPLE);123    await upsertPairedDevice(other);124    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);125    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();126  });127128  it("removePairedDeviceById drops the device and its IP cache", async () => {129    await upsertPairedDevice(SAMPLE);130    await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");131    await removePairedDeviceById(SAMPLE.deviceId);132    expect(await listPairedDevices()).toEqual([]);133    expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();134  });135136  it("clearPairedDevices wipes the whole registry", async () => {137    await upsertPairedDevice(SAMPLE);138    await clearPairedDevices();139    expect(await listPairedDevices()).toEqual([]);140  });141142  it("markLastSyncedAt mutates only the target device", async () => {143    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };144    await upsertPairedDevice(SAMPLE);145    await upsertPairedDevice(other);146    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);147    const found = await findPairedDeviceById(SAMPLE.deviceId);148    expect(found?.lastSyncedAt).toBe(1700001000000);149    const untouched = await findPairedDeviceById(other.deviceId);150    expect(untouched?.lastSyncedAt).toBeUndefined();151  });152153  it("moves every existing pair to a new mesh relay", async () => {154    const other: PairedDevice = {155      ...SAMPLE,156      deviceId: "BBB-222",157      longTermPublicKeyHex: "1".repeat(64),158      relayUrl: "wss://old-relay.example",159    };160    await upsertPairedDevice(SAMPLE);161    await upsertPairedDevice(other);162163    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");164165    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([166      "wss://new-relay.example/base",167      "wss://new-relay.example/base",168    ]);169  });170171  it("getLastKnownIp / setLastKnownIp round-trip", async () => {172    await setLastKnownIp("ab".repeat(32), "10.0.0.5");173    expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");174    await clearLastKnownIp("ab".repeat(32));175    expect(await getLastKnownIp("ab".repeat(32))).toBeNull();176  });177178  it("last sync timestamp accepts a legacy string value", async () => {179    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({180      peach_last_sync_at: "1700002000000",181    });182    expect(await getLastSyncAt()).toBe(1700002000000);183  });184185  it("setLastSyncAt persists a numeric value", async () => {186    await setLastSyncAt(1700003000000);187    expect(await getLastSyncAt()).toBe(1700003000000);188  });189190  it("listPairedDevices ignores garbage entries silently", async () => {191    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({192      peach_paired_devices_v3: [193        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },194        { not: "a device" },195        null,196        42,197      ],198    });199    const devices = await listPairedDevices();200    expect(devices).toHaveLength(1);201    expect(devices[0].deviceId).toBe("real");202  });203204  describe("device revocation", () => {205    it("revokeDeviceById records the device's key and drops it from the registry", async () => {206      await upsertPairedDevice(SAMPLE);207      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {208        now: 1_700_000_000_000,209      });210211      expect(record).toMatchObject({212        deviceId: SAMPLE.deviceId,213        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,214        revokedAt: 1_700_000_000_000,215        revokedByDeviceId: "this-device",216      });217      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();218      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);219    });220221    it("revoking twice is a no-op rather than a duplicate record", async () => {222      await upsertPairedDevice(SAMPLE);223      await revokeDeviceById(SAMPLE.deviceId, "this-device");224      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();225      expect(await listDeviceRevocations()).toHaveLength(1);226    });227228    it("an existing revocation still removes a stale re-paired row", async () => {229      await upsertPairedDevice(SAMPLE);230      await revokeDeviceById(SAMPLE.deviceId, "this-device");231      // Reproduce the real corrupt overlap created by older direct-pair232      // handlers: the row returned but its terminal revocation remained.233      await upsertPairedDevice(SAMPLE);234235      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();236      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();237      expect(await listDeviceRevocations()).toHaveLength(1);238    });239240    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {241      const first = await mergeDeviceRevocations([242        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },243      ]);244      expect(first).toEqual(["a"]);245246      const second = await mergeDeviceRevocations([247        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },248        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },249      ]);250      expect(second).toEqual(["b"]);251      expect(await listDeviceRevocations()).toHaveLength(2);252    });253254    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {255      await mergeDeviceRevocations([256        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },257      ]);258      await mergeDeviceRevocations([259        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },260      ]);261      const records = await listDeviceRevocations();262      expect(records).toHaveLength(1);263      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });264    });265266    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {267      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;268      await mergeDeviceRevocations([269        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },270      ]);271      const [record] = await listDeviceRevocations();272      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);273    });274275    it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {276      await upsertPairedDevice(SAMPLE);277      await revokeDeviceById(SAMPLE.deviceId, "this-device");278      await clearDeviceRevocation(SAMPLE.deviceId);279280      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);281      expect(await listDeviceRevocations()).toHaveLength(0);282    });283284    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {285      const now = Date.now();286      await mergeDeviceRevocations([{287        deviceId: SAMPLE.deviceId,288        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,289        revokedAt: now - 1_000,290        revokedByDeviceId: "this-device",291      }]);292      await upsertPairedDevice({293        ...SAMPLE,294        pairedAt: now,295        trustSource: "direct",296      });297298      expect(await repairPairedRevocationOverlaps()).toEqual({299        revocationsCleared: 1,300        pairedRowsRemoved: 0,301      });302      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();303      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);304    });305306    it("startup repair removes a direct pair older than its revocation", async () => {307      const now = Date.now();308      await upsertPairedDevice({309        ...SAMPLE,310        pairedAt: now - 2_000,311        trustSource: "direct",312      });313      await mergeDeviceRevocations([{314        deviceId: SAMPLE.deviceId,315        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,316        revokedAt: now - 1_000,317        revokedByDeviceId: "this-device",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    });327328    it("startup repair never lets a newer vouched pair lift a revocation", async () => {329      const now = Date.now();330      await mergeDeviceRevocations([{331        deviceId: SAMPLE.deviceId,332        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,333        revokedAt: now - 1_000,334        revokedByDeviceId: "this-device",335      }]);336      await upsertPairedDevice({337        ...SAMPLE,338        pairedAt: now,339        trustSource: "vouched",340      });341342      expect(await repairPairedRevocationOverlaps()).toEqual({343        revocationsCleared: 0,344        pairedRowsRemoved: 1,345      });346      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();347      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);348    });349  });350});