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 TrustDataCorruptedError,20 upsertPairedDevice,21 validatePairedDeviceIdentities,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 signingPublicKeyHex: "1".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 metadata in place, no duplicate", async () => {73 await upsertPairedDevice(SAMPLE);74 await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel", pairedAt: 1700000001000 });75 const all = await listPairedDevices();76 expect(all).toHaveLength(1);77 expect(all[0]).toMatchObject({78 deviceName: "Renamed Pixel",79 pairedAt: 1700000001000,80 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,81 signingPublicKeyHex: SAMPLE.signingPublicKeyHex,82 });83 });8485 it("stores a local alias without changing the peer-reported name or identity", async () => {86 await upsertPairedDevice(SAMPLE);87 await renamePairedDevice(SAMPLE.deviceId, " Travel phone ");88 const renamed = await findPairedDeviceById(SAMPLE.deviceId);89 expect(renamed).toMatchObject({90 deviceName: SAMPLE.deviceName,91 localAlias: "Travel phone",92 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,93 });94 });9596 it("rejects blank, oversized, and unknown-device aliases", async () => {97 await upsertPairedDevice(SAMPLE);98 await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);99 await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);100 await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);101 });102103 it("rejects a same-deviceId long-term key replacement and leaves the stored row unchanged", async () => {104 await upsertPairedDevice(SAMPLE);105 const before = await findPairedDeviceById(SAMPLE.deviceId);106 await expect(107 upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: "f".repeat(64) }),108 ).rejects.toThrow("device_identity_immutable");109 expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);110 });111112 it("rejects a same-deviceId signing key replacement and leaves the stored row unchanged", async () => {113 await upsertPairedDevice(SAMPLE);114 const before = await findPairedDeviceById(SAMPLE.deviceId);115 await expect(116 upsertPairedDevice({ ...SAMPLE, signingPublicKeyHex: "2".repeat(64) }),117 ).rejects.toThrow("device_identity_immutable");118 expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);119 });120 it("preserves an existing signing key when an incoming row omits or nulls it", async () => {121 await upsertPairedDevice(SAMPLE);122 await upsertPairedDevice({ ...SAMPLE, deviceName: "Updated Pixel", signingPublicKeyHex: undefined });123 await upsertPairedDevice({124 ...SAMPLE,125 deviceName: "Updated Again",126 signingPublicKeyHex: null as unknown as string,127 });128 expect((await findPairedDeviceById(SAMPLE.deviceId))?.signingPublicKeyHex).toBe(129 SAMPLE.signingPublicKeyHex,130 );131 });132133 it("rejects duplicate manifest identities before any registry mutation", async () => {134 await expect(135 validatePairedDeviceIdentities([136 { ...SAMPLE, deviceName: "First" },137 { ...SAMPLE, deviceName: "Second", longTermPublicKeyHex: "f".repeat(64) },138 ]),139 ).rejects.toThrow("device_identity_immutable");140 expect(await listPairedDevices()).toEqual([]);141 });142 it("rejects upsert with missing fields", async () => {143 await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);144 await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);145 await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);146 await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);147 });148149 it("caps oversized peer device names at the registry seam", async () => {150 await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });151 const found = await findPairedDeviceById("LONG-1");152 expect(found?.deviceName).toBe("N".repeat(64));153 });154155 it("findPairedDeviceByPubkey returns the correct device", async () => {156 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };157 await upsertPairedDevice(SAMPLE);158 await upsertPairedDevice(other);159 expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);160 expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();161 });162163 it("removePairedDeviceById drops the device", async () => {164 await upsertPairedDevice(SAMPLE);165 await removePairedDeviceById(SAMPLE.deviceId);166 expect(await listPairedDevices()).toEqual([]);167 });168169 it("clearPairedDevices wipes the whole registry", async () => {170 await upsertPairedDevice(SAMPLE);171 await clearPairedDevices();172 expect(await listPairedDevices()).toEqual([]);173 });174175 it("markLastSyncedAt mutates only the target device", async () => {176 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };177 await upsertPairedDevice(SAMPLE);178 await upsertPairedDevice(other);179 await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);180 const found = await findPairedDeviceById(SAMPLE.deviceId);181 expect(found?.lastSyncedAt).toBe(1700001000000);182 const untouched = await findPairedDeviceById(other.deviceId);183 expect(untouched?.lastSyncedAt).toBeUndefined();184 });185186 it("serializes a concurrent pairing upsert with a sync timestamp update", async () => {187 const other: PairedDevice = {188 ...SAMPLE,189 deviceId: "BBB-222",190 longTermPublicKeyHex: "1".repeat(64),191 };192 await upsertPairedDevice(SAMPLE);193194 await Promise.all([195 upsertPairedDevice(other),196 markLastSyncedAt(SAMPLE.deviceId, 1700001000000),197 ]);198199 expect(await listPairedDevices()).toEqual([200 { ...SAMPLE, lastSyncedAt: 1700001000000 },201 other,202 ]);203 });204205 it("moves every existing pair to a new mesh relay", async () => {206 const other: PairedDevice = {207 ...SAMPLE,208 deviceId: "BBB-222",209 longTermPublicKeyHex: "1".repeat(64),210 relayUrl: "wss://old-relay.example",211 };212 await upsertPairedDevice(SAMPLE);213 await upsertPairedDevice(other);214215 await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");216217 expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([218 "wss://new-relay.example/base",219 "wss://new-relay.example/base",220 ]);221 });222223 it("last sync timestamp accepts a legacy string value", async () => {224 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({225 peach_last_sync_at: "1700002000000",226 });227 expect(await getLastSyncAt()).toBe(1700002000000);228 });229230 it("setLastSyncAt persists a numeric value", async () => {231 await setLastSyncAt(1700003000000);232 expect(await getLastSyncAt()).toBe(1700003000000);233 });234235 it("listPairedDevices filters invalid entries from an otherwise valid array", async () => {236 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({237 peach_paired_devices_v3: [238 { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },239 { not: "a device" },240 null,241 42,242 ],243 });244 const devices = await listPairedDevices();245 expect(devices).toHaveLength(1);246 expect(devices[0].deviceId).toBe("real");247 });248249 250 251 it("listPairedDevices throws TrustDataCorruptedError on a non-array registry", async () => {252 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({253 peach_paired_devices_v3: { corrupted: true },254 });255 await expect(listPairedDevices()).rejects.toThrow(TrustDataCorruptedError);256 });257258 it("listDeviceRevocations throws TrustDataCorruptedError on a non-array ledger", async () => {259 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({260 peach_device_revocations_v1: "not-an-array",261 });262 await expect(listDeviceRevocations()).rejects.toThrow(TrustDataCorruptedError);263 });264265 it("a corrupt registry is not overwritten by a mutation attempt", async () => {266 const corrupt = { corrupted: true };267 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({268 peach_paired_devices_v3: corrupt,269 });270 await expect(upsertPairedDevice(SAMPLE)).rejects.toThrow(TrustDataCorruptedError);271 272 const result = await chrome.storage.local.get("peach_paired_devices_v3");273 expect(result["peach_paired_devices_v3"]).toBe(corrupt);274 });275276 describe("device revocation", () => {277 it("revokeDeviceById records the device's key and drops it from the registry", async () => {278 await upsertPairedDevice(SAMPLE);279 const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {280 now: 1_700_000_000_000,281 });282283 expect(record).toMatchObject({284 deviceId: SAMPLE.deviceId,285 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,286 revokedAt: 1_700_000_000_000,287 revokedByDeviceId: "this-device",288 });289 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();290 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);291 });292293 it("revoking twice is a no-op rather than a duplicate record", async () => {294 await upsertPairedDevice(SAMPLE);295 await revokeDeviceById(SAMPLE.deviceId, "this-device");296 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();297 expect(await listDeviceRevocations()).toHaveLength(1);298 });299300 it("an existing revocation still removes a stale re-paired row", async () => {301 await upsertPairedDevice(SAMPLE);302 await revokeDeviceById(SAMPLE.deviceId, "this-device");303 304 305 await upsertPairedDevice(SAMPLE);306307 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();308 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();309 expect(await listDeviceRevocations()).toHaveLength(1);310 });311312 it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {313 const first = await mergeDeviceRevocations([314 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },315 ]);316 expect(first).toEqual(["a"]);317318 const second = await mergeDeviceRevocations([319 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },320 { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },321 ]);322 expect(second).toEqual(["b"]);323 expect(await listDeviceRevocations()).toHaveLength(2);324 });325326 327 328 329 330 it("a later revocation from a different issuer is still admitted on a full ledger", async () => {331 await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({332 deviceId: `terminal-${index}`,333 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),334 revokedAt: index + 1,335 revokedByDeviceId: "issuer-a",336 })));337338 expect(await mergeDeviceRevocations([{339 deviceId: "peer-extra",340 longTermPublicKeyHex: "f".repeat(64),341 revokedAt: 999,342 revokedByDeviceId: "issuer-b",343 }])).toEqual(["peer-extra"]);344345 const records = await listDeviceRevocations();346 expect(records).toHaveLength(500);347 348 expect(await isDeviceRevoked("peer-extra")).toBe(true);349 350 351 expect(records.filter((r) => r.revokedByDeviceId === "issuer-b")).toHaveLength(1);352 expect(await isDeviceRevoked("terminal-0")).toBe(false);353 });354355 it("a same-issuer local flood retains the bounded ledger", async () => {356 for (let index = 0; index < 501; index += 1) {357 await upsertPairedDevice({358 ...SAMPLE,359 deviceId: `local-${index}`,360 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),361 });362 await revokeDeviceById(`local-${index}`, "issuer-a", { now: index + 1 });363 }364 const records = await listDeviceRevocations();365 expect(records).toHaveLength(500);366 expect(records.some((record) => record.deviceId === "local-0")).toBe(false);367 expect(records.at(-1)?.deviceId).toBe("local-500");368 });369370 it("a different issuer local revoke preserves one of its genuine records", async () => {371 await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({372 deviceId: `terminal-${index}`,373 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),374 revokedAt: index + 1,375 revokedByDeviceId: index === 0 ? "issuer-a" : "issuer-b",376 })));377 await upsertPairedDevice({ ...SAMPLE, deviceId: "local-new", longTermPublicKeyHex: "f".repeat(64) });378 await revokeDeviceById("local-new", "issuer-a", { now: 501 });379 const records = await listDeviceRevocations();380 expect(records).toHaveLength(500);381 expect(records.some((record) => record.deviceId === "terminal-0")).toBe(true);382 expect(records.some((record) => record.deviceId === "local-new")).toBe(true);383 });384385 it("a peer flood cannot evict an older revocation from another issuer", async () => {386 await mergeDeviceRevocations([{387 deviceId: "genuine",388 longTermPublicKeyHex: "a".repeat(64),389 revokedAt: 1,390 revokedByDeviceId: "honest-issuer",391 }]);392 const flood = Array.from({ length: 600 }, (_, index) => ({393 deviceId: `junk-${index}`,394 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),395 revokedAt: index + 2,396 revokedByDeviceId: "flooding-issuer",397 }));398 const admitted = await mergeDeviceRevocations(flood);399 expect(admitted).toHaveLength(600);400 const records = await listDeviceRevocations();401 expect(records).toHaveLength(500);402 expect(await isDeviceRevoked("genuine")).toBe(true);403 404 405 expect(await isDeviceRevoked("junk-599")).toBe(true);406 expect(await isDeviceRevoked("junk-0")).toBe(false);407 });408409 it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {410 await mergeDeviceRevocations([411 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },412 ]);413 await mergeDeviceRevocations([414 { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },415 ]);416 const records = await listDeviceRevocations();417 expect(records).toHaveLength(1);418 expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });419 });420421 it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {422 const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;423 await mergeDeviceRevocations([424 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },425 ]);426 const [record] = await listDeviceRevocations();427 expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);428 });429430 it("startup repair keeps a newer direct pair and clears its older revocation", async () => {431 const now = Date.now();432 await mergeDeviceRevocations([{433 deviceId: SAMPLE.deviceId,434 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,435 revokedAt: now - 1_000,436 revokedByDeviceId: "this-device",437 }]);438 await upsertPairedDevice({439 ...SAMPLE,440 pairedAt: now,441 trustSource: "direct",442 });443444 expect(await repairPairedRevocationOverlaps()).toEqual({445 revocationsCleared: 1,446 pairedRowsRemoved: 0,447 });448 expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();449 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);450 });451452 it("startup repair removes a direct pair older than its revocation", async () => {453 const now = Date.now();454 await upsertPairedDevice({455 ...SAMPLE,456 pairedAt: now - 2_000,457 trustSource: "direct",458 });459 await mergeDeviceRevocations([{460 deviceId: SAMPLE.deviceId,461 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,462 revokedAt: now - 1_000,463 revokedByDeviceId: "this-device",464 }]);465466 expect(await repairPairedRevocationOverlaps()).toEqual({467 revocationsCleared: 0,468 pairedRowsRemoved: 1,469 });470 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();471 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);472 });473474 it("startup repair never lets a newer vouched pair lift a revocation", async () => {475 const now = Date.now();476 await mergeDeviceRevocations([{477 deviceId: SAMPLE.deviceId,478 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,479 revokedAt: now - 1_000,480 revokedByDeviceId: "this-device",481 }]);482 await upsertPairedDevice({483 ...SAMPLE,484 pairedAt: now,485 trustSource: "vouched",486 });487488 expect(await repairPairedRevocationOverlaps()).toEqual({489 revocationsCleared: 0,490 pairedRowsRemoved: 1,491 });492 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();493 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);494 });495 });496});