paired-devices.test.ts

12.4 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("findPairedDeviceByPubkey returns the correct device", async () => {96    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };97    await upsertPairedDevice(SAMPLE);98    await upsertPairedDevice(other);99    expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);100    expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();101  });102103  it("removePairedDeviceById drops the device and its IP cache", async () => {104    await upsertPairedDevice(SAMPLE);105    await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");106    await removePairedDeviceById(SAMPLE.deviceId);107    expect(await listPairedDevices()).toEqual([]);108    expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();109  });110111  it("clearPairedDevices wipes the whole registry", async () => {112    await upsertPairedDevice(SAMPLE);113    await clearPairedDevices();114    expect(await listPairedDevices()).toEqual([]);115  });116117  it("markLastSyncedAt mutates only the target device", async () => {118    const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };119    await upsertPairedDevice(SAMPLE);120    await upsertPairedDevice(other);121    await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);122    const found = await findPairedDeviceById(SAMPLE.deviceId);123    expect(found?.lastSyncedAt).toBe(1700001000000);124    const untouched = await findPairedDeviceById(other.deviceId);125    expect(untouched?.lastSyncedAt).toBeUndefined();126  });127128  it("moves every existing pair to a new mesh relay", async () => {129    const other: PairedDevice = {130      ...SAMPLE,131      deviceId: "BBB-222",132      longTermPublicKeyHex: "1".repeat(64),133      relayUrl: "wss://old-relay.example",134    };135    await upsertPairedDevice(SAMPLE);136    await upsertPairedDevice(other);137138    await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");139140    expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([141      "wss://new-relay.example/base",142      "wss://new-relay.example/base",143    ]);144  });145146  it("getLastKnownIp / setLastKnownIp round-trip", async () => {147    await setLastKnownIp("ab".repeat(32), "10.0.0.5");148    expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");149    await clearLastKnownIp("ab".repeat(32));150    expect(await getLastKnownIp("ab".repeat(32))).toBeNull();151  });152153  it("last sync timestamp accepts a legacy string value", async () => {154    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({155      peach_last_sync_at: "1700002000000",156    });157    expect(await getLastSyncAt()).toBe(1700002000000);158  });159160  it("setLastSyncAt persists a numeric value", async () => {161    await setLastSyncAt(1700003000000);162    expect(await getLastSyncAt()).toBe(1700003000000);163  });164165  it("listPairedDevices ignores garbage entries silently", async () => {166    (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({167      peach_paired_devices_v3: [168        { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },169        { not: "a device" },170        null,171        42,172      ],173    });174    const devices = await listPairedDevices();175    expect(devices).toHaveLength(1);176    expect(devices[0].deviceId).toBe("real");177  });178179  describe("device revocation", () => {180    it("revokeDeviceById records the device's key and drops it from the registry", async () => {181      await upsertPairedDevice(SAMPLE);182      const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {183        now: 1_700_000_000_000,184      });185186      expect(record).toMatchObject({187        deviceId: SAMPLE.deviceId,188        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,189        revokedAt: 1_700_000_000_000,190        revokedByDeviceId: "this-device",191      });192      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();193      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);194    });195196    it("revoking twice is a no-op rather than a duplicate record", async () => {197      await upsertPairedDevice(SAMPLE);198      await revokeDeviceById(SAMPLE.deviceId, "this-device");199      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();200      expect(await listDeviceRevocations()).toHaveLength(1);201    });202203    it("an existing revocation still removes a stale re-paired row", async () => {204      await upsertPairedDevice(SAMPLE);205      await revokeDeviceById(SAMPLE.deviceId, "this-device");206      // Reproduce the real corrupt overlap created by older direct-pair207      // handlers: the row returned but its terminal revocation remained.208      await upsertPairedDevice(SAMPLE);209210      expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();211      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();212      expect(await listDeviceRevocations()).toHaveLength(1);213    });214215    it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {216      const first = await mergeDeviceRevocations([217        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },218      ]);219      expect(first).toEqual(["a"]);220221      const second = await mergeDeviceRevocations([222        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },223        { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },224      ]);225      expect(second).toEqual(["b"]);226      expect(await listDeviceRevocations()).toHaveLength(2);227    });228229    it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {230      await mergeDeviceRevocations([231        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },232      ]);233      await mergeDeviceRevocations([234        { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },235      ]);236      const records = await listDeviceRevocations();237      expect(records).toHaveLength(1);238      expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });239    });240241    it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {242      const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;243      await mergeDeviceRevocations([244        { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },245      ]);246      const [record] = await listDeviceRevocations();247      expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);248    });249250    it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {251      await upsertPairedDevice(SAMPLE);252      await revokeDeviceById(SAMPLE.deviceId, "this-device");253      await clearDeviceRevocation(SAMPLE.deviceId);254255      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);256      expect(await listDeviceRevocations()).toHaveLength(0);257    });258259    it("startup repair keeps a newer direct pair and clears its older revocation", async () => {260      const now = Date.now();261      await mergeDeviceRevocations([{262        deviceId: SAMPLE.deviceId,263        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,264        revokedAt: now - 1_000,265        revokedByDeviceId: "this-device",266      }]);267      await upsertPairedDevice({268        ...SAMPLE,269        pairedAt: now,270        trustSource: "direct",271      });272273      expect(await repairPairedRevocationOverlaps()).toEqual({274        revocationsCleared: 1,275        pairedRowsRemoved: 0,276      });277      expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();278      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);279    });280281    it("startup repair removes a direct pair older than its revocation", async () => {282      const now = Date.now();283      await upsertPairedDevice({284        ...SAMPLE,285        pairedAt: now - 2_000,286        trustSource: "direct",287      });288      await mergeDeviceRevocations([{289        deviceId: SAMPLE.deviceId,290        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,291        revokedAt: now - 1_000,292        revokedByDeviceId: "this-device",293      }]);294295      expect(await repairPairedRevocationOverlaps()).toEqual({296        revocationsCleared: 0,297        pairedRowsRemoved: 1,298      });299      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();300      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);301    });302303    it("startup repair never lets a newer vouched pair lift a revocation", async () => {304      const now = Date.now();305      await mergeDeviceRevocations([{306        deviceId: SAMPLE.deviceId,307        longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,308        revokedAt: now - 1_000,309        revokedByDeviceId: "this-device",310      }]);311      await upsertPairedDevice({312        ...SAMPLE,313        pairedAt: now,314        trustSource: "vouched",315      });316317      expect(await repairPairedRevocationOverlaps()).toEqual({318        revocationsCleared: 0,319        pairedRowsRemoved: 1,320      });321      expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();322      expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);323    });324  });325});