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("mergeDeviceRevocations reports only the ids it had not already seen", async () => {203 const first = await mergeDeviceRevocations([204 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },205 ]);206 expect(first).toEqual(["a"]);207208 const second = await mergeDeviceRevocations([209 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },210 { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },211 ]);212 expect(second).toEqual(["b"]);213 expect(await listDeviceRevocations()).toHaveLength(2);214 });215216 it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {217 await mergeDeviceRevocations([218 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },219 ]);220 await mergeDeviceRevocations([221 { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },222 ]);223 const records = await listDeviceRevocations();224 expect(records).toHaveLength(1);225 expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });226 });227228 it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {229 await upsertPairedDevice(SAMPLE);230 await revokeDeviceById(SAMPLE.deviceId, "this-device");231 await clearDeviceRevocation(SAMPLE.deviceId);232233 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);234 expect(await listDeviceRevocations()).toHaveLength(0);235 });236 });237});