paired-devices.test.ts

10.2 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  removePairedDeviceById,17  revokeDeviceById,18  setLastKnownIp,19  setLastSyncAt,20  setRelayUrlForAllPairedDevices,21  upsertPairedDevice,22} from "./paired-devices";2324function makeStorageStub() {25  const sync = new Map<string, unknown>();26  const local = new Map<string, unknown>();27  function area(store: Map<string, unknown>) {28    return {29      async get(key?: string | string[] | null) {30        if (key === null || key === undefined) {31          const all: Record<string, unknown> = {};32          for (const [k, v] of store) all[k] = v;33          return all;34        }35        const keys = Array.isArray(key) ? key : [key];36        const out: Record<string, unknown> = {};37        for (const k of keys) if (store.has(k)) out[k] = store.get(k);38        return out;39      },40      async set(values: Record<string, unknown>) {41        for (const [k, v] of Object.entries(values)) store.set(k, v);42      },43      async remove(key: string | string[]) {44        const keys = Array.isArray(key) ? key : [key];45        for (const k of keys) store.delete(k);46      },47    };48  }49  (globalThis as unknown as { chrome: unknown }).chrome = {50    storage: { sync: area(sync), local: area(local) },51  };52  return { sync, local };53}5455const SAMPLE: PairedDevice = {56  deviceId: "AAA-111",57  deviceName: "June's Pixel 9",58  deviceType: "android",59  longTermPublicKeyHex: "0".repeat(64),60  pairedAt: 1700000000000,61};6263describe("paired-devices registry", () => {64  beforeEach(() => makeStorageStub());6566  it("upsert + list", async () => {67    await upsertPairedDevice(SAMPLE);68    expect(await listPairedDevices()).toEqual([SAMPLE]);69  });7071  it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {72    await upsertPairedDevice(SAMPLE);73    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });74    const all = await listPairedDevices();75    expect(all).toHaveLength(1);76    expect(all[0].deviceName).toBe("Renamed Pixel");77  });7879  it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {80    await upsertPairedDevice(SAMPLE);81    const newPubkey = "f".repeat(64);82    await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });83    const found = await findPairedDeviceById(SAMPLE.deviceId);84    expect(found?.longTermPublicKeyHex).toBe(newPubkey);85  });8687  it("rejects upsert with missing fields", async () => {88    await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);89    await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);90    await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);91    await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);92  });9394  it("findPairedDeviceByPubkey returns the correct device", async () => {95    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };96    await upsertPairedDevice(SAMPLE);97    await upsertPairedDevice(other);98    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);99    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();100  });101102  it("removePairedDeviceById drops the device and its IP cache", async () => {103    await upsertPairedDevice(SAMPLE);104    await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");105    await removePairedDeviceById(SAMPLE.deviceId);106    expect(await listPairedDevices()).toEqual([]);107    expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();108  });109110  it("clearPairedDevices wipes the whole registry", async () => {111    await upsertPairedDevice(SAMPLE);112    await clearPairedDevices();113    expect(await listPairedDevices()).toEqual([]);114  });115116  it("markLastSyncedAt mutates only the target device", async () => {117    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };118    await upsertPairedDevice(SAMPLE);119    await upsertPairedDevice(other);120    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);121    const found = await findPairedDeviceById(SAMPLE.deviceId);122    expect(found?.lastSyncedAt).toBe(1700001000000);123    const untouched = await findPairedDeviceById(other.deviceId);124    expect(untouched?.lastSyncedAt).toBeUndefined();125  });126127  it("moves every existing pair to a new mesh relay", async () => {128    const other: PairedDevice = {129      ...SAMPLE,130      deviceId: "BBB-222",131      longTermPublicKeyHex: "1".repeat(64),132      relayUrl: "wss://old-relay.example",133    };134    await upsertPairedDevice(SAMPLE);135    await upsertPairedDevice(other);136137    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");138139    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([140      "wss://new-relay.example/base",141      "wss://new-relay.example/base",142    ]);143  });144145  it("getLastKnownIp / setLastKnownIp round-trip", async () => {146    await setLastKnownIp("ab".repeat(32), "10.0.0.5");147    expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");148    await clearLastKnownIp("ab".repeat(32));149    expect(await getLastKnownIp("ab".repeat(32))).toBeNull();150  });151152  it("last sync timestamp accepts a legacy string value", async () => {153    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({154      peach_last_sync_at: "1700002000000",155    });156    expect(await getLastSyncAt()).toBe(1700002000000);157  });158159  it("setLastSyncAt persists a numeric value", async () => {160    await setLastSyncAt(1700003000000);161    expect(await getLastSyncAt()).toBe(1700003000000);162  });163164  it("listPairedDevices ignores garbage entries silently", async () => {165    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({166      peach_paired_devices_v3: [167        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },168        { not: "a device" },169        null,170        42,171      ],172    });173    const devices = await listPairedDevices();174    expect(devices).toHaveLength(1);175    expect(devices[0].deviceId).toBe("real");176  });177178  describe("device revocation", () => {179    it("revokeDeviceById records the device's key and drops it from the registry", async () => {180      await upsertPairedDevice(SAMPLE);181      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {182        now: 1_700_000_000_000,183      });184185      expect(record).toMatchObject({186        deviceId: SAMPLE.deviceId,187        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,188        revokedAt: 1_700_000_000_000,189        revokedByDeviceId: "this-device",190      });191      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();192      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);193    });194195    it("revoking twice is a no-op rather than a duplicate record", async () => {196      await upsertPairedDevice(SAMPLE);197      await revokeDeviceById(SAMPLE.deviceId, "this-device");198      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();199      expect(await listDeviceRevocations()).toHaveLength(1);200    });201202    it("an existing revocation still removes a stale re-paired row", async () => {203      await upsertPairedDevice(SAMPLE);204      await revokeDeviceById(SAMPLE.deviceId, "this-device");205      // Reproduce the real corrupt overlap created by older direct-pair206      // handlers: the row returned but its terminal revocation remained.207      await upsertPairedDevice(SAMPLE);208209      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();210      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();211      expect(await listDeviceRevocations()).toHaveLength(1);212    });213214    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {215      const first = await mergeDeviceRevocations([216        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },217      ]);218      expect(first).toEqual(["a"]);219220      const second = await mergeDeviceRevocations([221        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },222        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },223      ]);224      expect(second).toEqual(["b"]);225      expect(await listDeviceRevocations()).toHaveLength(2);226    });227228    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {229      await mergeDeviceRevocations([230        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },231      ]);232      await mergeDeviceRevocations([233        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },234      ]);235      const records = await listDeviceRevocations();236      expect(records).toHaveLength(1);237      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });238    });239240    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {241      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;242      await mergeDeviceRevocations([243        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },244      ]);245      const [record] = await listDeviceRevocations();246      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);247    });248249    it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {250      await upsertPairedDevice(SAMPLE);251      await revokeDeviceById(SAMPLE.deviceId, "this-device");252      await clearDeviceRevocation(SAMPLE.deviceId);253254      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);255      expect(await listDeviceRevocations()).toHaveLength(0);256    });257  });258});