1import { describe, expect, it, beforeEach } from "vitest";2import type { PairedDevice } from "../shared/sync-types";3import {4 clearPairedDevices,5 findPairedDeviceById,6 findPairedDeviceByPubkey,7 getLastSyncAt,8 listPairedDevices,9 markLastSyncedAt,10 isDeviceRevoked,11 listDeviceRevocations,12 mergeDeviceRevocations,13 repairPairedRevocationOverlaps,14 removePairedDeviceById,15 renamePairedDevice,16 revokeDeviceById,17 setLastSyncAt,18 setRelayUrlForAllPairedDevices,19 upsertPairedDevice,20} from "./paired-devices";2122function makeStorageStub() {23 const sync = new Map<string, unknown>();24 const local = new Map<string, unknown>();25 function area(store: Map<string, unknown>) {26 return {27 async get(key?: string | string[] | null) {28 if (key === null || key === undefined) {29 const all: Record<string, unknown> = {};30 for (const [k, v] of store) all[k] = v;31 return all;32 }33 const keys = Array.isArray(key) ? key : [key];34 const out: Record<string, unknown> = {};35 for (const k of keys) if (store.has(k)) out[k] = store.get(k);36 return out;37 },38 async set(values: Record<string, unknown>) {39 for (const [k, v] of Object.entries(values)) store.set(k, v);40 },41 async remove(key: string | string[]) {42 const keys = Array.isArray(key) ? key : [key];43 for (const k of keys) store.delete(k);44 },45 };46 }47 (globalThis as unknown as { chrome: unknown }).chrome = {48 storage: { sync: area(sync), local: area(local) },49 };50 return { sync, local };51}5253const SAMPLE: PairedDevice = {54 deviceId: "AAA-111",55 deviceName: "June's Pixel 9",56 deviceType: "android",57 longTermPublicKeyHex: "0".repeat(64),58 pairedAt: 1700000000000,59};6061describe("paired-devices registry", () => {62 beforeEach(() => makeStorageStub());6364 it("upsert + list", async () => {65 await upsertPairedDevice(SAMPLE);66 expect(await listPairedDevices()).toEqual([SAMPLE]);67 });6869 it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {70 await upsertPairedDevice(SAMPLE);71 await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });72 const all = await listPairedDevices();73 expect(all).toHaveLength(1);74 expect(all[0].deviceName).toBe("Renamed Pixel");75 });7677 it("stores a local alias without changing the peer-reported name or identity", async () => {78 await upsertPairedDevice(SAMPLE);79 await renamePairedDevice(SAMPLE.deviceId, " Travel phone ");80 const renamed = await findPairedDeviceById(SAMPLE.deviceId);81 expect(renamed).toMatchObject({82 deviceName: SAMPLE.deviceName,83 localAlias: "Travel phone",84 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,85 });86 });8788 it("rejects blank, oversized, and unknown-device aliases", async () => {89 await upsertPairedDevice(SAMPLE);90 await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);91 await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);92 await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);93 });9495 it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {96 await upsertPairedDevice(SAMPLE);97 const newPubkey = "f".repeat(64);98 await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });99 const found = await findPairedDeviceById(SAMPLE.deviceId);100 expect(found?.longTermPublicKeyHex).toBe(newPubkey);101 });102103 it("rejects upsert with missing fields", async () => {104 await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);105 await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);106 await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);107 await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);108 });109110 it("caps oversized peer device names at the registry seam", async () => {111 await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });112 const found = await findPairedDeviceById("LONG-1");113 expect(found?.deviceName).toBe("N".repeat(64));114 });115116 it("findPairedDeviceByPubkey returns the correct device", async () => {117 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };118 await upsertPairedDevice(SAMPLE);119 await upsertPairedDevice(other);120 expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);121 expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();122 });123124 it("removePairedDeviceById drops the device", async () => {125 await upsertPairedDevice(SAMPLE);126 await removePairedDeviceById(SAMPLE.deviceId);127 expect(await listPairedDevices()).toEqual([]);128 });129130 it("clearPairedDevices wipes the whole registry", async () => {131 await upsertPairedDevice(SAMPLE);132 await clearPairedDevices();133 expect(await listPairedDevices()).toEqual([]);134 });135136 it("markLastSyncedAt mutates only the target device", async () => {137 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };138 await upsertPairedDevice(SAMPLE);139 await upsertPairedDevice(other);140 await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);141 const found = await findPairedDeviceById(SAMPLE.deviceId);142 expect(found?.lastSyncedAt).toBe(1700001000000);143 const untouched = await findPairedDeviceById(other.deviceId);144 expect(untouched?.lastSyncedAt).toBeUndefined();145 });146147 it("serializes a concurrent pairing upsert with a sync timestamp update", async () => {148 const other: PairedDevice = {149 ...SAMPLE,150 deviceId: "BBB-222",151 longTermPublicKeyHex: "1".repeat(64),152 };153 await upsertPairedDevice(SAMPLE);154155 await Promise.all([156 upsertPairedDevice(other),157 markLastSyncedAt(SAMPLE.deviceId, 1700001000000),158 ]);159160 expect(await listPairedDevices()).toEqual([161 { ...SAMPLE, lastSyncedAt: 1700001000000 },162 other,163 ]);164 });165166 it("moves every existing pair to a new mesh relay", async () => {167 const other: PairedDevice = {168 ...SAMPLE,169 deviceId: "BBB-222",170 longTermPublicKeyHex: "1".repeat(64),171 relayUrl: "wss://old-relay.example",172 };173 await upsertPairedDevice(SAMPLE);174 await upsertPairedDevice(other);175176 await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");177178 expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([179 "wss://new-relay.example/base",180 "wss://new-relay.example/base",181 ]);182 });183184 it("last sync timestamp accepts a legacy string value", async () => {185 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({186 peach_last_sync_at: "1700002000000",187 });188 expect(await getLastSyncAt()).toBe(1700002000000);189 });190191 it("setLastSyncAt persists a numeric value", async () => {192 await setLastSyncAt(1700003000000);193 expect(await getLastSyncAt()).toBe(1700003000000);194 });195196 it("listPairedDevices ignores garbage entries silently", async () => {197 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({198 peach_paired_devices_v3: [199 { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },200 { not: "a device" },201 null,202 42,203 ],204 });205 const devices = await listPairedDevices();206 expect(devices).toHaveLength(1);207 expect(devices[0].deviceId).toBe("real");208 });209210 describe("device revocation", () => {211 it("revokeDeviceById records the device's key and drops it from the registry", async () => {212 await upsertPairedDevice(SAMPLE);213 const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {214 now: 1_700_000_000_000,215 });216217 expect(record).toMatchObject({218 deviceId: SAMPLE.deviceId,219 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,220 revokedAt: 1_700_000_000_000,221 revokedByDeviceId: "this-device",222 });223 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();224 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);225 });226227 it("revoking twice is a no-op rather than a duplicate record", async () => {228 await upsertPairedDevice(SAMPLE);229 await revokeDeviceById(SAMPLE.deviceId, "this-device");230 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();231 expect(await listDeviceRevocations()).toHaveLength(1);232 });233234 it("an existing revocation still removes a stale re-paired row", async () => {235 await upsertPairedDevice(SAMPLE);236 await revokeDeviceById(SAMPLE.deviceId, "this-device");237 238 239 await upsertPairedDevice(SAMPLE);240241 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();242 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();243 expect(await listDeviceRevocations()).toHaveLength(1);244 });245246 it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {247 const first = await mergeDeviceRevocations([248 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },249 ]);250 expect(first).toEqual(["a"]);251252 const second = await mergeDeviceRevocations([253 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },254 { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },255 ]);256 expect(second).toEqual(["b"]);257 expect(await listDeviceRevocations()).toHaveLength(2);258 });259260 261 262 263 264 it("a later revocation from a different issuer is still admitted on a full ledger", async () => {265 await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({266 deviceId: `terminal-${index}`,267 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),268 revokedAt: index + 1,269 revokedByDeviceId: "issuer-a",270 })));271272 expect(await mergeDeviceRevocations([{273 deviceId: "peer-extra",274 longTermPublicKeyHex: "f".repeat(64),275 revokedAt: 999,276 revokedByDeviceId: "issuer-b",277 }])).toEqual(["peer-extra"]);278279 const records = await listDeviceRevocations();280 expect(records).toHaveLength(500);281 282 expect(await isDeviceRevoked("peer-extra")).toBe(true);283 284 285 expect(records.filter((r) => r.revokedByDeviceId === "issuer-b")).toHaveLength(1);286 expect(await isDeviceRevoked("terminal-0")).toBe(false);287 });288289 it("a same-issuer local flood retains the bounded ledger", async () => {290 for (let index = 0; index < 501; index += 1) {291 await upsertPairedDevice({292 ...SAMPLE,293 deviceId: `local-${index}`,294 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),295 });296 await revokeDeviceById(`local-${index}`, "issuer-a", { now: index + 1 });297 }298 const records = await listDeviceRevocations();299 expect(records).toHaveLength(500);300 expect(records.some((record) => record.deviceId === "local-0")).toBe(false);301 expect(records.at(-1)?.deviceId).toBe("local-500");302 });303304 it("a different issuer local revoke preserves one of its genuine records", async () => {305 await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({306 deviceId: `terminal-${index}`,307 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),308 revokedAt: index + 1,309 revokedByDeviceId: index === 0 ? "issuer-a" : "issuer-b",310 })));311 await upsertPairedDevice({ ...SAMPLE, deviceId: "local-new", longTermPublicKeyHex: "f".repeat(64) });312 await revokeDeviceById("local-new", "issuer-a", { now: 501 });313 const records = await listDeviceRevocations();314 expect(records).toHaveLength(500);315 expect(records.some((record) => record.deviceId === "terminal-0")).toBe(true);316 expect(records.some((record) => record.deviceId === "local-new")).toBe(true);317 });318319 it("a peer flood cannot evict an older revocation from another issuer", async () => {320 await mergeDeviceRevocations([{321 deviceId: "genuine",322 longTermPublicKeyHex: "a".repeat(64),323 revokedAt: 1,324 revokedByDeviceId: "honest-issuer",325 }]);326 const flood = Array.from({ length: 600 }, (_, index) => ({327 deviceId: `junk-${index}`,328 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),329 revokedAt: index + 2,330 revokedByDeviceId: "flooding-issuer",331 }));332 const admitted = await mergeDeviceRevocations(flood);333 expect(admitted).toHaveLength(600);334 const records = await listDeviceRevocations();335 expect(records).toHaveLength(500);336 expect(await isDeviceRevoked("genuine")).toBe(true);337 338 339 expect(await isDeviceRevoked("junk-599")).toBe(true);340 expect(await isDeviceRevoked("junk-0")).toBe(false);341 });342343 it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {344 await mergeDeviceRevocations([345 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },346 ]);347 await mergeDeviceRevocations([348 { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },349 ]);350 const records = await listDeviceRevocations();351 expect(records).toHaveLength(1);352 expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });353 });354355 it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {356 const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;357 await mergeDeviceRevocations([358 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },359 ]);360 const [record] = await listDeviceRevocations();361 expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);362 });363364 it("startup repair keeps a newer direct pair and clears its older revocation", async () => {365 const now = Date.now();366 await mergeDeviceRevocations([{367 deviceId: SAMPLE.deviceId,368 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,369 revokedAt: now - 1_000,370 revokedByDeviceId: "this-device",371 }]);372 await upsertPairedDevice({373 ...SAMPLE,374 pairedAt: now,375 trustSource: "direct",376 });377378 expect(await repairPairedRevocationOverlaps()).toEqual({379 revocationsCleared: 1,380 pairedRowsRemoved: 0,381 });382 expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();383 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);384 });385386 it("startup repair removes a direct pair older than its revocation", async () => {387 const now = Date.now();388 await upsertPairedDevice({389 ...SAMPLE,390 pairedAt: now - 2_000,391 trustSource: "direct",392 });393 await mergeDeviceRevocations([{394 deviceId: SAMPLE.deviceId,395 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,396 revokedAt: now - 1_000,397 revokedByDeviceId: "this-device",398 }]);399400 expect(await repairPairedRevocationOverlaps()).toEqual({401 revocationsCleared: 0,402 pairedRowsRemoved: 1,403 });404 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();405 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);406 });407408 it("startup repair never lets a newer vouched pair lift a revocation", async () => {409 const now = Date.now();410 await mergeDeviceRevocations([{411 deviceId: SAMPLE.deviceId,412 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,413 revokedAt: now - 1_000,414 revokedByDeviceId: "this-device",415 }]);416 await upsertPairedDevice({417 ...SAMPLE,418 pairedAt: now,419 trustSource: "vouched",420 });421422 expect(await repairPairedRevocationOverlaps()).toEqual({423 revocationsCleared: 0,424 pairedRowsRemoved: 1,425 });426 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();427 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);428 });429 });430});