// PCH-02-001 regression tests — UPDATE_ENTRY patch path must route // canary/tags/passkey through the same sanitizers the add/restore/sync // funnels use (sanitizeCanary / sanitizePasskey / sanitizeTags). import { beforeEach, describe, expect, it, vi } from "vitest"; import type { PasskeyData, VaultEntry } from "../shared/types"; type StorageShape = Record; describe("PCH-02-001 — updateEntry patch sanitizers", () => { let storage: StorageShape; beforeEach(() => { vi.resetModules(); storage = {}; const chromeMock = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === "string") { return { [key]: storage[key] }; } return key.reduce>((result, entry) => { result[entry] = storage[entry]; return result; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() }, }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; // Keep the fire-and-forget breach check off the network. vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled in tests"); })); }); async function vaultWithEntry(extra: Partial = {}) { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const entry = await vault.addEntry({ title: "Example", username: "june", password: "secret-1", url: "https://login.example.com", ...extra, } as Parameters[0]); return { vault, entry }; } const validPasskey: PasskeyData = { rpId: "example.com", rpName: "Example", credentialId: "Y3JlZC0x", publicKeyCose: "cGstY29zZQ", userHandle: "dXNlci0x", userName: "june", userDisplayName: "June", algorithm: -7, signCount: 0, aaguid: "", backupEligible: false, backupState: false, createdAt: 1_700_000_000_000, }; it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => { const { vault, entry } = await vaultWithEntry(); await vault.updateEntry(entry.id, { canary: { webhookUrl: "http://attacker.example.com/exfil" }, }); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.canary).toBeUndefined(); }); it("preserves a valid https canary (and its bump fields) through a patch", async () => { const { vault, entry } = await vaultWithEntry(); // Shape the content-script canary-ping bump sends (content/index.ts:336-348). await vault.updateEntry(entry.id, { canary: { webhookUrl: "https://legit.example.com/hook", label: "desk drawer", lastPinged: 1_700_000_111_000, pingCount: 3, }, }); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.canary).toEqual({ webhookUrl: "https://legit.example.com/hook", label: "desk drawer", lastPinged: 1_700_000_111_000, pingCount: 3, }); }); it("rebuilds the canary field-by-field — unknown keys are dropped", async () => { const { vault, entry } = await vaultWithEntry(); await vault.updateEntry(entry.id, { canary: { webhookUrl: "https://legit.example.com/hook", exfil: "smuggled", __proto__: { polluted: true }, } as unknown as VaultEntry["canary"], }); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.canary?.webhookUrl).toBe("https://legit.example.com/hook"); const keys = Object.keys(updated!.canary!); expect(keys.every((k) => ["webhookUrl", "label", "lastPinged", "pingCount"].includes(k))).toBe(true); const canaryRecord = updated!.canary as unknown as Record; expect(canaryRecord.exfil).toBeUndefined(); expect(canaryRecord.polluted).toBeUndefined(); }); it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => { const { vault, entry } = await vaultWithEntry(); await expect( vault.updateEntry(entry.id, { passkey: { rpId: "example.com", publicKeyCose: "cGstY29zZQ", userHandle: "dXNlci0x", userName: "june", } as unknown as PasskeyData, }), ).rejects.toThrow("invalid passkey credential"); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.passkey).toBeUndefined(); expect(updated?.password).toBe("secret-1"); }); it("accepts a complete passkey patch (field-by-field rebuild)", async () => { const { vault, entry } = await vaultWithEntry(); await vault.updateEntry(entry.id, { passkey: validPasskey }); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.passkey?.rpId).toBe("example.com"); expect(updated?.passkey?.credentialId).toBe("Y3JlZC0x"); expect(updated?.passkey?.algorithm).toBe(-7); }); it("still removes a passkey via `passkey: undefined`", async () => { const { vault, entry } = await vaultWithEntry(); await vault.updateEntry(entry.id, { passkey: validPasskey }); await vault.updateEntry(entry.id, { passkey: undefined }); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.passkey).toBeUndefined(); }); it("collapses a proto-pollution tags shape (non-array) to undefined", async () => { const { vault, entry } = await vaultWithEntry(); await vault.updateEntry(entry.id, { tags: ["keep-me"] }); await vault.updateEntry(entry.id, { tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[], }); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.tags).toBeUndefined(); expect(({} as Record).polluted).toBeUndefined(); }); it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => { const { vault, entry } = await vaultWithEntry(); const oversized = "x".repeat(500); const many = Array.from({ length: 300 }, (_, i) => `tag-${i}`); await vault.updateEntry(entry.id, { tags: [oversized, 42, null, " ", ...many] as unknown as string[], }); const updated = vault.getEntries().find((e) => e.id === entry.id); expect(updated?.tags).toBeDefined(); expect(updated!.tags!.length).toBeLessThanOrEqual(255); expect(updated!.tags![0]).toBe("x".repeat(256)); expect(updated!.tags!.every((t) => typeof t === "string" && t.length <= 256)).toBe(true); }); });