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 validatePairedDeviceIdentities,21} from "./paired-devices";2223function makeStorageStub() {24 const sync = new Map<string, unknown>();25 const local = new Map<string, unknown>();26 function area(store: Map<string, unknown>) {27 return {28 async get(key?: string | string[] | null) {29 if (key === null || key === undefined) {30 const all: Record<string, unknown> = {};31 for (const [k, v] of store) all[k] = v;32 return all;33 }34 const keys = Array.isArray(key) ? key : [key];35 const out: Record<string, unknown> = {};36 for (const k of keys) if (store.has(k)) out[k] = store.get(k);37 return out;38 },39 async set(values: Record<string, unknown>) {40 for (const [k, v] of Object.entries(values)) store.set(k, v);41 },42 async remove(key: string | string[]) {43 const keys = Array.isArray(key) ? key : [key];44 for (const k of keys) store.delete(k);45 },46 };47 }48 (globalThis as unknown as { chrome: unknown }).chrome = {49 storage: { sync: area(sync), local: area(local) },50 };51 return { sync, local };52}5354const SAMPLE: PairedDevice = {55 deviceId: "AAA-111",56 deviceName: "June's Pixel 9",57 deviceType: "android",58 longTermPublicKeyHex: "0".repeat(64),59 signingPublicKeyHex: "1".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 metadata in place, no duplicate", async () => {72 await upsertPairedDevice(SAMPLE);73 await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel", pairedAt: 1700000001000 });74 const all = await listPairedDevices();75 expect(all).toHaveLength(1);76 expect(all[0]).toMatchObject({77 deviceName: "Renamed Pixel",78 pairedAt: 1700000001000,79 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,80 signingPublicKeyHex: SAMPLE.signingPublicKeyHex,81 });82 });8384 it("stores a local alias without changing the peer-reported name or identity", async () => {85 await upsertPairedDevice(SAMPLE);86 await renamePairedDevice(SAMPLE.deviceId, " Travel phone ");87 const renamed = await findPairedDeviceById(SAMPLE.deviceId);88 expect(renamed).toMatchObject({89 deviceName: SAMPLE.deviceName,90 localAlias: "Travel phone",91 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,92 });93 });9495 it("rejects blank, oversized, and unknown-device aliases", async () => {96 await upsertPairedDevice(SAMPLE);97 await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);98 await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);99 await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);100 });101102 it("rejects a same-deviceId long-term key replacement and leaves the stored row unchanged", async () => {103 await upsertPairedDevice(SAMPLE);104 const before = await findPairedDeviceById(SAMPLE.deviceId);105 await expect(106 upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: "f".repeat(64) }),107 ).rejects.toThrow("device_identity_immutable");108 expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);109 });110111 it("rejects a same-deviceId signing key replacement and leaves the stored row unchanged", async () => {112 await upsertPairedDevice(SAMPLE);113 const before = await findPairedDeviceById(SAMPLE.deviceId);114 await expect(115 upsertPairedDevice({ ...SAMPLE, signingPublicKeyHex: "2".repeat(64) }),116 ).rejects.toThrow("device_identity_immutable");117 expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);118 });119 it("preserves an existing signing key when an incoming row omits or nulls it", async () => {120 await upsertPairedDevice(SAMPLE);121 await upsertPairedDevice({ ...SAMPLE, deviceName: "Updated Pixel", signingPublicKeyHex: undefined });122 await upsertPairedDevice({123 ...SAMPLE,124 deviceName: "Updated Again",125 signingPublicKeyHex: null as unknown as string,126 });127 expect((await findPairedDeviceById(SAMPLE.deviceId))?.signingPublicKeyHex).toBe(128 SAMPLE.signingPublicKeyHex,129 );130 });131132 it("rejects duplicate manifest identities before any registry mutation", async () => {133 await expect(134 validatePairedDeviceIdentities([135 { ...SAMPLE, deviceName: "First" },136 { ...SAMPLE, deviceName: "Second", longTermPublicKeyHex: "f".repeat(64) },137 ]),138 ).rejects.toThrow("device_identity_immutable");139 expect(await listPairedDevices()).toEqual([]);140 });141 it("rejects upsert with missing fields", async () => {142 await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);143 await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);144 await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);145 await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);146 });147148 it("caps oversized peer device names at the registry seam", async () => {149 await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });150 const found = await findPairedDeviceById("LONG-1");151 expect(found?.deviceName).toBe("N".repeat(64));152 });153154 it("findPairedDeviceByPubkey returns the correct device", async () => {155 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };156 await upsertPairedDevice(SAMPLE);157 await upsertPairedDevice(other);158 expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);159 expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();160 });161162 it("removePairedDeviceById drops the device", async () => {163 await upsertPairedDevice(SAMPLE);164 await removePairedDeviceById(SAMPLE.deviceId);165 expect(await listPairedDevices()).toEqual([]);166 });167168 it("clearPairedDevices wipes the whole registry", async () => {169 await upsertPairedDevice(SAMPLE);170 await clearPairedDevices();171 expect(await listPairedDevices()).toEqual([]);172 });173174 it("markLastSyncedAt mutates only the target device", async () => {175 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };176 await upsertPairedDevice(SAMPLE);177 await upsertPairedDevice(other);178 await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);179 const found = await findPairedDeviceById(SAMPLE.deviceId);180 expect(found?.lastSyncedAt).toBe(1700001000000);181 const untouched = await findPairedDeviceById(other.deviceId);182 expect(untouched?.lastSyncedAt).toBeUndefined();183 });184185 it("serializes a concurrent pairing upsert with a sync timestamp update", async () => {186 const other: PairedDevice = {187 ...SAMPLE,188 deviceId: "BBB-222",189 longTermPublicKeyHex: "1".repeat(64),190 };191 await upsertPairedDevice(SAMPLE);192193 await Promise.all([194 upsertPairedDevice(other),195 markLastSyncedAt(SAMPLE.deviceId, 1700001000000),196 ]);197198 expect(await listPairedDevices()).toEqual([199 { ...SAMPLE, lastSyncedAt: 1700001000000 },200 other,201 ]);202 });203204 it("moves every existing pair to a new mesh relay", async () => {205 const other: PairedDevice = {206 ...SAMPLE,207 deviceId: "BBB-222",208 longTermPublicKeyHex: "1".repeat(64),209 relayUrl: "wss://old-relay.example",210 };211 await upsertPairedDevice(SAMPLE);212 await upsertPairedDevice(other);213214 await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");215216 expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([217 "wss://new-relay.example/base",218 "wss://new-relay.example/base",219 ]);220 });221222 it("last sync timestamp accepts a legacy string value", async () => {223 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({224 peach_last_sync_at: "1700002000000",225 });226 expect(await getLastSyncAt()).toBe(1700002000000);227 });228229 it("setLastSyncAt persists a numeric value", async () => {230 await setLastSyncAt(1700003000000);231 expect(await getLastSyncAt()).toBe(1700003000000);232 });233234 it("listPairedDevices ignores garbage entries silently", async () => {235 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({236 peach_paired_devices_v3: [237 { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },238 { not: "a device" },239 null,240 42,241 ],242 });243 const devices = await listPairedDevices();244 expect(devices).toHaveLength(1);245 expect(devices[0].deviceId).toBe("real");246 });247248 describe("device revocation", () => {249 it("revokeDeviceById records the device's key and drops it from the registry", async () => {250 await upsertPairedDevice(SAMPLE);251 const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {252 now: 1_700_000_000_000,253 });254255 expect(record).toMatchObject({256 deviceId: SAMPLE.deviceId,257 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,258 revokedAt: 1_700_000_000_000,259 revokedByDeviceId: "this-device",260 });261 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();262 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);263 });264265 it("revoking twice is a no-op rather than a duplicate record", async () => {266 await upsertPairedDevice(SAMPLE);267 await revokeDeviceById(SAMPLE.deviceId, "this-device");268 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();269 expect(await listDeviceRevocations()).toHaveLength(1);270 });271272 it("an existing revocation still removes a stale re-paired row", async () => {273 await upsertPairedDevice(SAMPLE);274 await revokeDeviceById(SAMPLE.deviceId, "this-device");275 276 277 await upsertPairedDevice(SAMPLE);278279 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();280 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();281 expect(await listDeviceRevocations()).toHaveLength(1);282 });283284 it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {285 const first = await mergeDeviceRevocations([286 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },287 ]);288 expect(first).toEqual(["a"]);289290 const second = await mergeDeviceRevocations([291 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },292 { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },293 ]);294 expect(second).toEqual(["b"]);295 expect(await listDeviceRevocations()).toHaveLength(2);296 });297298 299 300 301 302 it("a later revocation from a different issuer is still admitted on a full ledger", async () => {303 await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({304 deviceId: `terminal-${index}`,305 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),306 revokedAt: index + 1,307 revokedByDeviceId: "issuer-a",308 })));309310 expect(await mergeDeviceRevocations([{311 deviceId: "peer-extra",312 longTermPublicKeyHex: "f".repeat(64),313 revokedAt: 999,314 revokedByDeviceId: "issuer-b",315 }])).toEqual(["peer-extra"]);316317 const records = await listDeviceRevocations();318 expect(records).toHaveLength(500);319 320 expect(await isDeviceRevoked("peer-extra")).toBe(true);321 322 323 expect(records.filter((r) => r.revokedByDeviceId === "issuer-b")).toHaveLength(1);324 expect(await isDeviceRevoked("terminal-0")).toBe(false);325 });326327 it("a same-issuer local flood retains the bounded ledger", async () => {328 for (let index = 0; index < 501; index += 1) {329 await upsertPairedDevice({330 ...SAMPLE,331 deviceId: `local-${index}`,332 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),333 });334 await revokeDeviceById(`local-${index}`, "issuer-a", { now: index + 1 });335 }336 const records = await listDeviceRevocations();337 expect(records).toHaveLength(500);338 expect(records.some((record) => record.deviceId === "local-0")).toBe(false);339 expect(records.at(-1)?.deviceId).toBe("local-500");340 });341342 it("a different issuer local revoke preserves one of its genuine records", async () => {343 await mergeDeviceRevocations(Array.from({ length: 500 }, (_, index) => ({344 deviceId: `terminal-${index}`,345 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),346 revokedAt: index + 1,347 revokedByDeviceId: index === 0 ? "issuer-a" : "issuer-b",348 })));349 await upsertPairedDevice({ ...SAMPLE, deviceId: "local-new", longTermPublicKeyHex: "f".repeat(64) });350 await revokeDeviceById("local-new", "issuer-a", { now: 501 });351 const records = await listDeviceRevocations();352 expect(records).toHaveLength(500);353 expect(records.some((record) => record.deviceId === "terminal-0")).toBe(true);354 expect(records.some((record) => record.deviceId === "local-new")).toBe(true);355 });356357 it("a peer flood cannot evict an older revocation from another issuer", async () => {358 await mergeDeviceRevocations([{359 deviceId: "genuine",360 longTermPublicKeyHex: "a".repeat(64),361 revokedAt: 1,362 revokedByDeviceId: "honest-issuer",363 }]);364 const flood = Array.from({ length: 600 }, (_, index) => ({365 deviceId: `junk-${index}`,366 longTermPublicKeyHex: index.toString(16).padStart(64, "0"),367 revokedAt: index + 2,368 revokedByDeviceId: "flooding-issuer",369 }));370 const admitted = await mergeDeviceRevocations(flood);371 expect(admitted).toHaveLength(600);372 const records = await listDeviceRevocations();373 expect(records).toHaveLength(500);374 expect(await isDeviceRevoked("genuine")).toBe(true);375 376 377 expect(await isDeviceRevoked("junk-599")).toBe(true);378 expect(await isDeviceRevoked("junk-0")).toBe(false);379 });380381 it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {382 await mergeDeviceRevocations([383 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },384 ]);385 await mergeDeviceRevocations([386 { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },387 ]);388 const records = await listDeviceRevocations();389 expect(records).toHaveLength(1);390 expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });391 });392393 it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {394 const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;395 await mergeDeviceRevocations([396 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },397 ]);398 const [record] = await listDeviceRevocations();399 expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);400 });401402 it("startup repair keeps a newer direct pair and clears its older revocation", async () => {403 const now = Date.now();404 await mergeDeviceRevocations([{405 deviceId: SAMPLE.deviceId,406 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,407 revokedAt: now - 1_000,408 revokedByDeviceId: "this-device",409 }]);410 await upsertPairedDevice({411 ...SAMPLE,412 pairedAt: now,413 trustSource: "direct",414 });415416 expect(await repairPairedRevocationOverlaps()).toEqual({417 revocationsCleared: 1,418 pairedRowsRemoved: 0,419 });420 expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();421 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);422 });423424 it("startup repair removes a direct pair older than its revocation", async () => {425 const now = Date.now();426 await upsertPairedDevice({427 ...SAMPLE,428 pairedAt: now - 2_000,429 trustSource: "direct",430 });431 await mergeDeviceRevocations([{432 deviceId: SAMPLE.deviceId,433 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,434 revokedAt: now - 1_000,435 revokedByDeviceId: "this-device",436 }]);437438 expect(await repairPairedRevocationOverlaps()).toEqual({439 revocationsCleared: 0,440 pairedRowsRemoved: 1,441 });442 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();443 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);444 });445446 it("startup repair never lets a newer vouched pair lift a revocation", async () => {447 const now = Date.now();448 await mergeDeviceRevocations([{449 deviceId: SAMPLE.deviceId,450 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,451 revokedAt: now - 1_000,452 revokedByDeviceId: "this-device",453 }]);454 await upsertPairedDevice({455 ...SAMPLE,456 pairedAt: now,457 trustSource: "vouched",458 });459460 expect(await repairPairedRevocationOverlaps()).toEqual({461 revocationsCleared: 0,462 pairedRowsRemoved: 1,463 });464 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();465 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);466 });467 });468});