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 213 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});