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 renamePairedDevice,19 revokeDeviceById,20 setLastKnownIp,21 setLastSyncAt,22 setRelayUrlForAllPairedDevices,23 upsertPairedDevice,24} from "./paired-devices";2526function makeStorageStub() {27 const sync = new Map<string, unknown>();28 const local = new Map<string, unknown>();29 function area(store: Map<string, unknown>) {30 return {31 async get(key?: string | string[] | null) {32 if (key === null || key === undefined) {33 const all: Record<string, unknown> = {};34 for (const [k, v] of store) all[k] = v;35 return all;36 }37 const keys = Array.isArray(key) ? key : [key];38 const out: Record<string, unknown> = {};39 for (const k of keys) if (store.has(k)) out[k] = store.get(k);40 return out;41 },42 async set(values: Record<string, unknown>) {43 for (const [k, v] of Object.entries(values)) store.set(k, v);44 },45 async remove(key: string | string[]) {46 const keys = Array.isArray(key) ? key : [key];47 for (const k of keys) store.delete(k);48 },49 };50 }51 (globalThis as unknown as { chrome: unknown }).chrome = {52 storage: { sync: area(sync), local: area(local) },53 };54 return { sync, local };55}5657const SAMPLE: PairedDevice = {58 deviceId: "AAA-111",59 deviceName: "June's Pixel 9",60 deviceType: "android",61 longTermPublicKeyHex: "0".repeat(64),62 pairedAt: 1700000000000,63};6465describe("paired-devices registry", () => {66 beforeEach(() => makeStorageStub());6768 it("upsert + list", async () => {69 await upsertPairedDevice(SAMPLE);70 expect(await listPairedDevices()).toEqual([SAMPLE]);71 });7273 it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {74 await upsertPairedDevice(SAMPLE);75 await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });76 const all = await listPairedDevices();77 expect(all).toHaveLength(1);78 expect(all[0].deviceName).toBe("Renamed Pixel");79 });8081 it("stores a local alias without changing the peer-reported name or identity", async () => {82 await upsertPairedDevice(SAMPLE);83 await renamePairedDevice(SAMPLE.deviceId, " Travel phone ");84 const renamed = await findPairedDeviceById(SAMPLE.deviceId);85 expect(renamed).toMatchObject({86 deviceName: SAMPLE.deviceName,87 localAlias: "Travel phone",88 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,89 });90 });9192 it("rejects blank, oversized, and unknown-device aliases", async () => {93 await upsertPairedDevice(SAMPLE);94 await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);95 await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);96 await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);97 });9899 it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {100 await upsertPairedDevice(SAMPLE);101 const newPubkey = "f".repeat(64);102 await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });103 const found = await findPairedDeviceById(SAMPLE.deviceId);104 expect(found?.longTermPublicKeyHex).toBe(newPubkey);105 });106107 it("rejects upsert with missing fields", async () => {108 await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);109 await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);110 await expect(upsertPairedDevice({ ...SAMPLE, deviceName: undefined })).rejects.toThrow(/deviceName/);111 await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);112 });113114 it("caps oversized peer device names at the registry seam", async () => {115 await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });116 const found = await findPairedDeviceById("LONG-1");117 expect(found?.deviceName).toBe("N".repeat(64));118 });119120 it("findPairedDeviceByPubkey returns the correct device", async () => {121 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };122 await upsertPairedDevice(SAMPLE);123 await upsertPairedDevice(other);124 expect(await findPairedDeviceByPubkey("1".repeat(64))).toEqual(other);125 expect(await findPairedDeviceByPubkey("9".repeat(64))).toBeNull();126 });127128 it("removePairedDeviceById drops the device and its IP cache", async () => {129 await upsertPairedDevice(SAMPLE);130 await setLastKnownIp(SAMPLE.longTermPublicKeyHex!, "192.168.1.42");131 await removePairedDeviceById(SAMPLE.deviceId);132 expect(await listPairedDevices()).toEqual([]);133 expect(await getLastKnownIp(SAMPLE.longTermPublicKeyHex!)).toBeNull();134 });135136 it("clearPairedDevices wipes the whole registry", async () => {137 await upsertPairedDevice(SAMPLE);138 await clearPairedDevices();139 expect(await listPairedDevices()).toEqual([]);140 });141142 it("markLastSyncedAt mutates only the target device", async () => {143 const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };144 await upsertPairedDevice(SAMPLE);145 await upsertPairedDevice(other);146 await markLastSyncedAt(SAMPLE.deviceId, 1700001000000);147 const found = await findPairedDeviceById(SAMPLE.deviceId);148 expect(found?.lastSyncedAt).toBe(1700001000000);149 const untouched = await findPairedDeviceById(other.deviceId);150 expect(untouched?.lastSyncedAt).toBeUndefined();151 });152153 it("moves every existing pair to a new mesh relay", async () => {154 const other: PairedDevice = {155 ...SAMPLE,156 deviceId: "BBB-222",157 longTermPublicKeyHex: "1".repeat(64),158 relayUrl: "wss://old-relay.example",159 };160 await upsertPairedDevice(SAMPLE);161 await upsertPairedDevice(other);162163 await setRelayUrlForAllPairedDevices("wss://new-relay.example/base");164165 expect((await listPairedDevices()).map((device) => device.relayUrl)).toEqual([166 "wss://new-relay.example/base",167 "wss://new-relay.example/base",168 ]);169 });170171 it("getLastKnownIp / setLastKnownIp round-trip", async () => {172 await setLastKnownIp("ab".repeat(32), "10.0.0.5");173 expect(await getLastKnownIp("ab".repeat(32))).toBe("10.0.0.5");174 await clearLastKnownIp("ab".repeat(32));175 expect(await getLastKnownIp("ab".repeat(32))).toBeNull();176 });177178 it("last sync timestamp accepts a legacy string value", async () => {179 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({180 peach_last_sync_at: "1700002000000",181 });182 expect(await getLastSyncAt()).toBe(1700002000000);183 });184185 it("setLastSyncAt persists a numeric value", async () => {186 await setLastSyncAt(1700003000000);187 expect(await getLastSyncAt()).toBe(1700003000000);188 });189190 it("listPairedDevices ignores garbage entries silently", async () => {191 (globalThis as unknown as { chrome: { storage: { local: { set: (v: Record<string, unknown>) => Promise<void> } } } }).chrome.storage.local.set({192 peach_paired_devices_v3: [193 { deviceId: "real", pairedAt: 1, deviceName: "x", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) },194 { not: "a device" },195 null,196 42,197 ],198 });199 const devices = await listPairedDevices();200 expect(devices).toHaveLength(1);201 expect(devices[0].deviceId).toBe("real");202 });203204 describe("device revocation", () => {205 it("revokeDeviceById records the device's key and drops it from the registry", async () => {206 await upsertPairedDevice(SAMPLE);207 const record = await revokeDeviceById(SAMPLE.deviceId, "this-device", {208 now: 1_700_000_000_000,209 });210211 expect(record).toMatchObject({212 deviceId: SAMPLE.deviceId,213 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,214 revokedAt: 1_700_000_000_000,215 revokedByDeviceId: "this-device",216 });217 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();218 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);219 });220221 it("revoking twice is a no-op rather than a duplicate record", async () => {222 await upsertPairedDevice(SAMPLE);223 await revokeDeviceById(SAMPLE.deviceId, "this-device");224 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();225 expect(await listDeviceRevocations()).toHaveLength(1);226 });227228 it("an existing revocation still removes a stale re-paired row", async () => {229 await upsertPairedDevice(SAMPLE);230 await revokeDeviceById(SAMPLE.deviceId, "this-device");231 232 233 await upsertPairedDevice(SAMPLE);234235 expect(await revokeDeviceById(SAMPLE.deviceId, "this-device")).toBeNull();236 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();237 expect(await listDeviceRevocations()).toHaveLength(1);238 });239240 it("mergeDeviceRevocations reports only the ids it had not already seen", async () => {241 const first = await mergeDeviceRevocations([242 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },243 ]);244 expect(first).toEqual(["a"]);245246 const second = await mergeDeviceRevocations([247 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 2, revokedByDeviceId: "y" },248 { deviceId: "b", longTermPublicKeyHex: "b".repeat(64), revokedAt: 3, revokedByDeviceId: "y" },249 ]);250 expect(second).toEqual(["b"]);251 expect(await listDeviceRevocations()).toHaveLength(2);252 });253254 it("a later revokedAt cannot displace an existing record — revocation is terminal", async () => {255 await mergeDeviceRevocations([256 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: 1, revokedByDeviceId: "x" },257 ]);258 await mergeDeviceRevocations([259 { deviceId: "a", longTermPublicKeyHex: "c".repeat(64), revokedAt: 9_999, revokedByDeviceId: "attacker" },260 ]);261 const records = await listDeviceRevocations();262 expect(records).toHaveLength(1);263 expect(records[0]).toMatchObject({ revokedAt: 1, revokedByDeviceId: "x" });264 });265266 it("HOLE 2: a far-future revokedAt is clamped rather than stored verbatim", async () => {267 const farFuture = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;268 await mergeDeviceRevocations([269 { deviceId: "a", longTermPublicKeyHex: "a".repeat(64), revokedAt: farFuture, revokedByDeviceId: "peer" },270 ]);271 const [record] = await listDeviceRevocations();272 expect(record.revokedAt).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);273 });274275 it("clearDeviceRevocation lifts the block so a direct re-pair can readmit", async () => {276 await upsertPairedDevice(SAMPLE);277 await revokeDeviceById(SAMPLE.deviceId, "this-device");278 await clearDeviceRevocation(SAMPLE.deviceId);279280 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);281 expect(await listDeviceRevocations()).toHaveLength(0);282 });283284 it("startup repair keeps a newer direct pair and clears its older revocation", async () => {285 const now = Date.now();286 await mergeDeviceRevocations([{287 deviceId: SAMPLE.deviceId,288 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,289 revokedAt: now - 1_000,290 revokedByDeviceId: "this-device",291 }]);292 await upsertPairedDevice({293 ...SAMPLE,294 pairedAt: now,295 trustSource: "direct",296 });297298 expect(await repairPairedRevocationOverlaps()).toEqual({299 revocationsCleared: 1,300 pairedRowsRemoved: 0,301 });302 expect(await findPairedDeviceById(SAMPLE.deviceId)).not.toBeNull();303 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(false);304 });305306 it("startup repair removes a direct pair older than its revocation", async () => {307 const now = Date.now();308 await upsertPairedDevice({309 ...SAMPLE,310 pairedAt: now - 2_000,311 trustSource: "direct",312 });313 await mergeDeviceRevocations([{314 deviceId: SAMPLE.deviceId,315 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,316 revokedAt: now - 1_000,317 revokedByDeviceId: "this-device",318 }]);319320 expect(await repairPairedRevocationOverlaps()).toEqual({321 revocationsCleared: 0,322 pairedRowsRemoved: 1,323 });324 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();325 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);326 });327328 it("startup repair never lets a newer vouched pair lift a revocation", async () => {329 const now = Date.now();330 await mergeDeviceRevocations([{331 deviceId: SAMPLE.deviceId,332 longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex!,333 revokedAt: now - 1_000,334 revokedByDeviceId: "this-device",335 }]);336 await upsertPairedDevice({337 ...SAMPLE,338 pairedAt: now,339 trustSource: "vouched",340 });341342 expect(await repairPairedRevocationOverlaps()).toEqual({343 revocationsCleared: 0,344 pairedRowsRemoved: 1,345 });346 expect(await findPairedDeviceById(SAMPLE.deviceId)).toBeNull();347 expect(await isDeviceRevoked(SAMPLE.deviceId)).toBe(true);348 });349 });350});