12345import { beforeEach, describe, expect, it, vi } from "vitest";6import type { PasskeyData, VaultEntry } from "../shared/types";78type StorageShape = Record<string, unknown>;910describe("PCH-02-001 — updateEntry patch sanitizers", () => {11 let storage: StorageShape;1213 beforeEach(() => {14 vi.resetModules();15 storage = {};1617 const chromeMock = {18 storage: {19 local: {20 get: vi.fn(async (key?: string | string[]) => {21 if (!key) return storage;22 if (typeof key === "string") {23 return { [key]: storage[key] };24 }25 return key.reduce<Record<string, unknown>>((result, entry) => {26 result[entry] = storage[entry];27 return result;28 }, {});29 }),30 set: vi.fn(async (next: Record<string, unknown>) => {31 Object.assign(storage, next);32 }),33 remove: vi.fn(async (keys: string | string[]) => {34 const list = Array.isArray(keys) ? keys : [keys];35 for (const key of list) delete storage[key];36 }),37 },38 },39 alarms: {40 clear: vi.fn(),41 create: vi.fn(),42 onAlarm: { addListener: vi.fn() },43 },44 idle: {45 setDetectionInterval: vi.fn(),46 onStateChanged: { addListener: vi.fn() },47 },48 } as unknown as typeof chrome;4950 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5152 53 vi.stubGlobal("fetch", vi.fn(async () => {54 throw new Error("network disabled in tests");55 }));56 });5758 async function vaultWithEntry(extra: Partial<VaultEntry> = {}) {59 const vault = await import("./vault");60 await vault.createVault("correct horse battery staple");61 const entry = await vault.addEntry({62 title: "Example",63 username: "june",64 password: "secret-1",65 url: "https://login.example.com",66 ...extra,67 } as Parameters<typeof vault.addEntry>[0]);68 return { vault, entry };69 }7071 const validPasskey: PasskeyData = {72 rpId: "example.com",73 rpName: "Example",74 credentialId: "Y3JlZC0x",75 publicKeyCose: "cGstY29zZQ",76 userHandle: "dXNlci0x",77 userName: "june",78 userDisplayName: "June",79 algorithm: -7,80 signCount: 0,81 aaguid: "",82 backupEligible: false,83 backupState: false,84 createdAt: 1_700_000_000_000,85 };8687 it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => {88 const { vault, entry } = await vaultWithEntry();8990 await vault.updateEntry(entry.id, {91 canary: { webhookUrl: "http://attacker.example.com/exfil" },92 });9394 const updated = vault.getEntries().find((e) => e.id === entry.id);95 expect(updated?.canary).toBeUndefined();96 });9798 it("preserves a valid https canary (and its bump fields) through a patch", async () => {99 const { vault, entry } = await vaultWithEntry();100101 102 await vault.updateEntry(entry.id, {103 canary: {104 webhookUrl: "https://legit.example.com/hook",105 label: "desk drawer",106 lastPinged: 1_700_000_111_000,107 pingCount: 3,108 },109 });110111 const updated = vault.getEntries().find((e) => e.id === entry.id);112 expect(updated?.canary).toEqual({113 webhookUrl: "https://legit.example.com/hook",114 label: "desk drawer",115 lastPinged: 1_700_000_111_000,116 pingCount: 3,117 });118 });119120 it("rebuilds the canary field-by-field — unknown keys are dropped", async () => {121 const { vault, entry } = await vaultWithEntry();122123 await vault.updateEntry(entry.id, {124 canary: {125 webhookUrl: "https://legit.example.com/hook",126 exfil: "smuggled",127 __proto__: { polluted: true },128 } as unknown as VaultEntry["canary"],129 });130131 const updated = vault.getEntries().find((e) => e.id === entry.id);132 expect(updated?.canary?.webhookUrl).toBe("https://legit.example.com/hook");133 const keys = Object.keys(updated!.canary!);134 expect(keys.every((k) => ["webhookUrl", "label", "lastPinged", "pingCount"].includes(k))).toBe(true);135 const canaryRecord = updated!.canary as unknown as Record<string, unknown>;136 expect(canaryRecord.exfil).toBeUndefined();137 expect(canaryRecord.polluted).toBeUndefined();138 });139140 it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => {141 const { vault, entry } = await vaultWithEntry();142143 await expect(144 vault.updateEntry(entry.id, {145 passkey: {146 rpId: "example.com",147 publicKeyCose: "cGstY29zZQ",148 userHandle: "dXNlci0x",149 userName: "june",150 } as unknown as PasskeyData,151 }),152 ).rejects.toThrow("invalid passkey credential");153154 const updated = vault.getEntries().find((e) => e.id === entry.id);155 expect(updated?.passkey).toBeUndefined();156 expect(updated?.password).toBe("secret-1");157 });158159 it("accepts a complete passkey patch (field-by-field rebuild)", async () => {160 const { vault, entry } = await vaultWithEntry();161162 await vault.updateEntry(entry.id, { passkey: validPasskey });163164 const updated = vault.getEntries().find((e) => e.id === entry.id);165 expect(updated?.passkey?.rpId).toBe("example.com");166 expect(updated?.passkey?.credentialId).toBe("Y3JlZC0x");167 expect(updated?.passkey?.algorithm).toBe(-7);168 });169170 it("still removes a passkey via `passkey: undefined`", async () => {171 const { vault, entry } = await vaultWithEntry();172 await vault.updateEntry(entry.id, { passkey: validPasskey });173174 await vault.updateEntry(entry.id, { passkey: undefined });175176 const updated = vault.getEntries().find((e) => e.id === entry.id);177 expect(updated?.passkey).toBeUndefined();178 });179180 it("collapses a proto-pollution tags shape (non-array) to undefined", async () => {181 const { vault, entry } = await vaultWithEntry();182 await vault.updateEntry(entry.id, { tags: ["keep-me"] });183184 await vault.updateEntry(entry.id, {185 tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[],186 });187188 const updated = vault.getEntries().find((e) => e.id === entry.id);189 expect(updated?.tags).toBeUndefined();190 expect(({} as Record<string, unknown>).polluted).toBeUndefined();191 });192193 it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => {194 const { vault, entry } = await vaultWithEntry();195196 const oversized = "x".repeat(500);197 const many = Array.from({ length: 300 }, (_, i) => `tag-${i}`);198 await vault.updateEntry(entry.id, {199 tags: [oversized, 42, null, " ", ...many] as unknown as string[],200 });201202 const updated = vault.getEntries().find((e) => e.id === entry.id);203 expect(updated?.tags).toBeDefined();204 expect(updated!.tags!.length).toBeLessThanOrEqual(255);205 expect(updated!.tags![0]).toBe("x".repeat(256));206 expect(updated!.tags!.every((t) => typeof t === "string" && t.length <= 256)).toBe(true);207 });208});