import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; // PCH-01-004: deviceId must live in chrome.storage.local (device-local), never // in chrome.storage.sync (which roams across profiles and collides identities). // // Each case re-imports the module after `vi.resetModules()` so the loader's // hydration cache starts empty; that module-loading boundary is the reason the // imports below are dynamic rather than static. type StorageShape = Record; const DEVICE_ID_KEY = "peach_device_id"; const IDENTITY_KEY = "peach_local_identity_v2"; const IDENTITY_INFO = "peach-local-identity-v2"; let local: StorageShape; let sync: StorageShape; let localSet: Mock; let syncSet: Mock; function installChromeStorageMock(): void { const makeArea = (store: StorageShape, set: Mock) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((r, k) => { r[k] = store[k]; return r; }, {}); }), set, remove: vi.fn(async () => undefined), }); local = {}; sync = {}; localSet = vi.fn(async (next: Record) => { Object.assign(local, next); }); syncSet = vi.fn(async (next: Record) => { Object.assign(sync, next); }); (globalThis as { chrome?: typeof chrome }).chrome = { storage: { local: makeArea(local, localSet), sync: makeArea(sync, syncSet), }, } as unknown as typeof chrome; } /** Encrypt an arbitrary payload into the identity slot, bypassing the loader. */ async function writeRawIdentity(payload: unknown, envelopeVersion = 3): Promise { const vault = await import("./vault"); const rootHex = vault.getRootKeyHex(); if (!rootHex) throw new Error("unlocked vault required"); const root = hexToBytes(rootHex); try { const aes = await import("./aes"); const sealed = await aes.encrypt(root, JSON.stringify(payload), IDENTITY_INFO); local[IDENTITY_KEY] = { v: envelopeVersion, ...sealed }; } finally { root.fill(0); } } /** Decrypt whatever the loader persisted, for schema assertions. */ async function readStoredIdentity(): Promise> { const vault = await import("./vault"); const rootHex = vault.getRootKeyHex(); if (!rootHex) throw new Error("unlocked vault required"); const row = local[IDENTITY_KEY] as Record; const root = hexToBytes(rootHex); try { const aes = await import("./aes"); return JSON.parse(await aes.decrypt(root, row.nonce, row.ciphertext, row.tag, IDENTITY_INFO)); } finally { root.fill(0); } } function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; } beforeEach(() => { vi.resetModules(); installChromeStorageMock(); }); describe("device-identity getOrCreateDeviceId (PCH-01-004)", () => { it("fresh_install_writes_id_to_local_only", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); const id = await getOrCreateDeviceId(); expect(typeof id).toBe("string"); expect(id.length).toBeGreaterThan(0); expect(local[DEVICE_ID_KEY]).toBeUndefined(); expect(local[IDENTITY_KEY]).toBeDefined(); expect(syncSet).not.toHaveBeenCalled(); }); it("returns_a_stable_id_across_calls", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); const first = await getOrCreateDeviceId(); const second = await getOrCreateDeviceId(); expect(second).toBe(first); }); it("replaces_a_copied_local_id_when_adoption_reports_a_collision", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId, rotateDeviceId } = await import("./device-identity"); const original = await getOrCreateDeviceId(); const fresh = await rotateDeviceId(); expect(fresh).not.toBe(original); expect(local[IDENTITY_KEY]).toBeDefined(); expect(await getOrCreateDeviceId()).toBe(fresh); expect(syncSet).not.toHaveBeenCalled(); }); }); describe("device-identity frozen schema (campaign task 2.1)", () => { it("persists every frozen field with public values matching the private material", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const { getOrCreateDeviceId } = await import("./device-identity"); const deviceId = await getOrCreateDeviceId(); const stored = await readStoredIdentity(); expect(stored.v).toBe(3); expect(stored.deviceId).toBe(deviceId); expect(stored.x25519PrivateKeyHex).toMatch(/^[0-9a-f]{64}$/); expect(stored.x25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/); expect(stored.ed25519SeedHex).toMatch(/^[0-9a-f]{64}$/); expect(stored.ed25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/); expect(stored.createdAt as number).toBeGreaterThan(0); }); it("completes a legacy random record in place instead of regenerating it", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); const legacy = { v: 2, deviceId: "legacy-device-id", x25519PrivateKeyHex: "11".repeat(32), ed25519SeedHex: "22".repeat(32), }; await writeRawIdentity(legacy, 2); const { getOrCreateDeviceId } = await import("./device-identity"); expect(await getOrCreateDeviceId()).toBe("legacy-device-id"); const stored = await readStoredIdentity(); expect(stored.v).toBe(3); expect(stored.deviceId).toBe("legacy-device-id"); // Same keys: the upgrade must never mint a new identity underneath pairings. expect(stored.x25519PrivateKeyHex).toBe("11".repeat(32)); expect(stored.ed25519SeedHex).toBe("22".repeat(32)); expect(stored.x25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/); expect(stored.ed25519PublicKeyHex).toMatch(/^[0-9a-f]{64}$/); }); it("rejects a frozen record whose stored public key does not match its private key", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); await writeRawIdentity({ v: 3, deviceId: "tampered", x25519PrivateKeyHex: "11".repeat(32), x25519PublicKeyHex: "33".repeat(32), ed25519SeedHex: "22".repeat(32), ed25519PublicKeyHex: "44".repeat(32), createdAt: 1_700_000_000_000, }); const { getOrCreateDeviceId } = await import("./device-identity"); await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); }); it("rejects a frozen record that is missing fields rather than defaulting them", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); await writeRawIdentity({ v: 3, deviceId: "partial", x25519PrivateKeyHex: "11".repeat(32), x25519PublicKeyHex: "33".repeat(32), ed25519SeedHex: "22".repeat(32), createdAt: 1_700_000_000_000, }); const { getOrCreateDeviceId } = await import("./device-identity"); await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); }); it("rejects a partial legacy record instead of minting a replacement", async () => { const vault = await import("./vault"); await vault.createVault("identity-test-password"); await writeRawIdentity({ v: 2, deviceId: "legacy-device-id", ed25519SeedHex: "22".repeat(32), }); const { getOrCreateDeviceId } = await import("./device-identity"); await expect(getOrCreateDeviceId()).rejects.toThrow("identity_migration_required"); }); });