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 clearDeviceRevocation,11 isDeviceRevoked,12 listDeviceRevocations,13 mergeDeviceRevocations,14 repairPairedRevocationOverlaps,15 removePairedDeviceById,16 renamePairedDevice,17 revokeDeviceById,18 setLastSyncAt,19 setRelayUrlForAllPairedDevices,20 upsertPairedDevice,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 pairedAt: 1700000000000,60};6162describe("paired-devices registry", () => {63 beforeEach(() => makeStorageStub());6465 it("upsert + list", async () => {66 await upsertPairedDevice(SAMPLE);67 expect(await listPairedDevices()).toEqual([SAMPLE]);68 });6970 it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {71 await upsertPairedDevice(SAMPLE);72 await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });73 const all = await listPairedDevices();74 expect(all).toHaveLength(1);75 expect(all[0].deviceName).toBe("Renamed Pixel");76 });7778 it("stores a local alias without changing the peer-reported name or identity", async () => {79 await upsertPairedDevice(SAMPLE);80 await renamePairedDevice(SAMPLE.deviceId, " Travel phone ");81 const renamed = await findPairedDeviceById(SAMPLE.deviceId);82 expect(renamed).toMatchObject({83 deviceName: SAMPLE.deviceName,84 localAlias: "Travel phone",85 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,86 });87 });8889 it("rejects blank, oversized, and unknown-device aliases", async () => {90 await upsertPairedDevice(SAMPLE);91 await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);92 await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);93 await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);94 });9596 it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {97 await upsertPairedDevice(SAMPLE);98 const newPubkey = "f".repeat(64);99 await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });100 const found = await findPairedDeviceById(SAMPLE.deviceId);101 expect(found?.longTermPublicKeyHex).toBe(newPubkey);102 });103104 it("rejects upsert with missing fields", async () => {105 await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);106 await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);107 await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);108 await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);109 });110111 it("caps oversized peer device names at the registry seam", async () => {112 await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });113 const found = await findPairedDeviceById("LONG-1");114 expect(found?.deviceName).toBe("N".repeat(64));115 });116117 it("findPairedDeviceByPubkey returns the correct device", async () => {118 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };119 await upsertPairedDevice(SAMPLE);120 await upsertPairedDevice(other);121 expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);122 expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();123 });124125 it("removePairedDeviceById drops the device", async () => {126 await upsertPairedDevice(SAMPLE);127 await removePairedDeviceById(SAMPLE.deviceId);128 expect(await listPairedDevices()).toEqual([]);129 });130131 it("clearPairedDevices wipes the whole registry", async () => {132 await upsertPairedDevice(SAMPLE);133 await clearPairedDevices();134 expect(await listPairedDevices()).toEqual([]);135 });136137 it("markLastSyncedAt mutates only the target device", async () => {138 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };139 await upsertPairedDevice(SAMPLE);140 await upsertPairedDevice(other);141 await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);142 const found = await findPairedDeviceById(SAMPLE.deviceId);143 expect(found?.lastSyncedAt).toBe(1700001000000);144 const untouched = await findPairedDeviceById(other.deviceId);145 expect(untouched?.lastSyncedAt).toBeUndefined();146 });147148 it("moves every existing pair to a new mesh relay", async () => {149 const other: PairedDevice = {150 ...SAMPLE,151 deviceId: "BBB-222",152 longTermPublicKeyHex: "1".repeat(64),153 relayUrl: "wss://old-relay.example",154 };155 await upsertPairedDevice(SAMPLE);156 await upsertPairedDevice(other);157158 await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");159160 expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([161 "wss://new-relay.example/base",162 "wss://new-relay.example/base",163 ]);164 });165166 it("last sync timestamp accepts a legacy string value", async () => {167 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({168 peach_last_sync_at: "1700002000000",169 });170 expect(await getLastSyncAt()).toBe(1700002000000);171 });172173 it("setLastSyncAt persists a numeric value", async () => {174 await setLastSyncAt(1700003000000);175 expect(await getLastSyncAt()).toBe(1700003000000);176 });177178 it("listPairedDevices ignores garbage entries silently", async () => {179 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({180 peach_paired_devices_v3: [181 { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },182 { not: "a device" },183 null,184 42,185 ],186 });187 const devices = await listPairedDevices();188 expect(devices).toHaveLength(1);189 expect(devices[0].deviceId).toBe("real");190 });191192 describe("device revocation", () => {193 it("revokeDeviceById records the device's key and drops it from the registry", async () => {194 await upsertPairedDevice(SAMPLE);195 const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {196 now: 1_700_000_000_000,197 });198199 expect(record).toMatchObject({200 deviceId: SAMPLE.deviceId,201 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,202 revokedAt: 1_700_000_000_000,203 revokedByDeviceId: "this-device",204 });205 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();206 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);207 });208209 it("revoking twice is a no-op rather than a duplicate record", async () => {210 await upsertPairedDevice(SAMPLE);211 await revokeDeviceById(SAMPLE.deviceId, "this-device");212 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();213 expect(await listDeviceRevocations()).toHaveLength(1);214 });215216 it("an existing revocation still removes a stale re-paired row", async () => {217 await upsertPairedDevice(SAMPLE);218 await revokeDeviceById(SAMPLE.deviceId, "this-device");219 220 221 await upsertPairedDevice(SAMPLE);222223 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();224 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();225 expect(await listDeviceRevocations()).toHaveLength(1);226 });227228 it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {229 const first = await mergeDeviceRevocations([230 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },231 ]);232 expect(first).toEqual(["a"]);233234 const second = await mergeDeviceRevocations([235 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },236 { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },237 ]);238 expect(second).toEqual(["b"]);239 expect(await listDeviceRevocations()).toHaveLength(2);240 });241242 it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {243 await mergeDeviceRevocations([244 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },245 ]);246 await mergeDeviceRevocations([247 { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },248 ]);249 const records = await listDeviceRevocations();250 expect(records).toHaveLength(1);251 expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });252 });253254 it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {255 const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;256 await mergeDeviceRevocations([257 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },258 ]);259 const [record] = await listDeviceRevocations();260 expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);261 });262263 it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {264 await upsertPairedDevice(SAMPLE);265 await revokeDeviceById(SAMPLE.deviceId, "this-device");266 await clearDeviceRevocation(SAMPLE.deviceId);267268 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);269 expect(await listDeviceRevocations()).toHaveLength(0);270 });271272 it("startup repair keeps a newer direct pair and clears its older revocation", async () => {273 const now = Date.now();274 await mergeDeviceRevocations([{275 deviceId: SAMPLE.deviceId,276 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,277 revokedAt: now - 1_000,278 revokedByDeviceId: "this-device",279 }]);280 await upsertPairedDevice({281 ...SAMPLE,282 pairedAt: now,283 trustSource: "direct",284 });285286 expect(await repairPairedRevocationOverlaps()).toEqual({287 revocationsCleared: 1,288 pairedRowsRemoved: 0,289 });290 expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();291 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);292 });293294 it("startup repair removes a direct pair older than its revocation", async () => {295 const now = Date.now();296 await upsertPairedDevice({297 ...SAMPLE,298 pairedAt: now - 2_000,299 trustSource: "direct",300 });301 await mergeDeviceRevocations([{302 deviceId: SAMPLE.deviceId,303 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,304 revokedAt: now - 1_000,305 revokedByDeviceId: "this-device",306 }]);307308 expect(await repairPairedRevocationOverlaps()).toEqual({309 revocationsCleared: 0,310 pairedRowsRemoved: 1,311 });312 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();313 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);314 });315316 it("startup repair never lets a newer vouched pair lift a revocation", async () => {317 const now = Date.now();318 await mergeDeviceRevocations([{319 deviceId: SAMPLE.deviceId,320 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,321 revokedAt: now - 1_000,322 revokedByDeviceId: "this-device",323 }]);324 await upsertPairedDevice({325 ...SAMPLE,326 pairedAt: now,327 trustSource: "vouched",328 });329330 expect(await repairPairedRevocationOverlaps()).toEqual({331 revocationsCleared: 0,332 pairedRowsRemoved: 1,333 });334 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();335 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);336 });337 });338});