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("preserves TOTP parameters through an UPDATE_ENTRY patch", async () => {88 const { vault, entry } = await vaultWithEntry({89 type: "login",90 totp: "JBSWY3DPEHPK3PXP",91 totpAlgorithm: "SHA256",92 totpDigits: 8,93 totpPeriod: 60,94 });9596 await vault.updateEntry(entry.id, {97 title: "Updated",98 totpAlgorithm: "SHA512",99 totpDigits: 6,100 totpPeriod: 45,101 });102103 expect(vault.getEntries().find((e) => e.id === entry.id)).toMatchObject({104 title: "Updated",105 totp: "JBSWY3DPEHPK3PXP",106 totpAlgorithm: "SHA512",107 totpDigits: 6,108 totpPeriod: 45,109 });110 });111112 it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => {113 const { vault, entry } = await vaultWithEntry();114115 await vault.updateEntry(entry.id, {116 canary: { webhookUrl: "http://attacker.example.com/exfil" },117 });118119 const updated = vault.getEntries().find((e) => e.id === entry.id);120 expect(updated?.canary).toBeUndefined();121 });122123 it("preserves a valid https canary (and its bump fields) through a patch", async () => {124 const { vault, entry } = await vaultWithEntry();125126 127 await vault.updateEntry(entry.id, {128 canary: {129 webhookUrl: "https://legit.example.com/hook",130 label: "desk drawer",131 lastPinged: 1_700_000_111_000,132 pingCount: 3,133 },134 });135136 const updated = vault.getEntries().find((e) => e.id === entry.id);137 expect(updated?.canary).toEqual({138 webhookUrl: "https://legit.example.com/hook",139 label: "desk drawer",140 lastPinged: 1_700_000_111_000,141 pingCount: 3,142 });143 });144145 it("rebuilds the canary field-by-field — unknown keys are dropped", async () => {146 const { vault, entry } = await vaultWithEntry();147148 await vault.updateEntry(entry.id, {149 canary: {150 webhookUrl: "https://legit.example.com/hook",151 exfil: "smuggled",152 __proto__: { polluted: true },153 } as unknown as VaultEntry["canary"],154 });155156 const updated = vault.getEntries().find((e) => e.id === entry.id);157 expect(updated?.canary?.webhookUrl).toBe("https://legit.example.com/hook");158 const keys = Object.keys(updated!.canary!);159 expect(keys.every((k) => ["webhookUrl", "label", "lastPinged", "pingCount"].includes(k))).toBe(true);160 const canaryRecord = updated!.canary as unknown as Record<string, unknown>;161 expect(canaryRecord.exfil).toBeUndefined();162 expect(canaryRecord.polluted).toBeUndefined();163 });164165 it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => {166 const { vault, entry } = await vaultWithEntry();167168 await expect(169 vault.updateEntry(entry.id, {170 passkey: {171 rpId: "example.com",172 publicKeyCose: "cGstY29zZQ",173 userHandle: "dXNlci0x",174 userName: "june",175 } as unknown as PasskeyData,176 }),177 ).rejects.toThrow("invalid passkey credential");178179 const updated = vault.getEntries().find((e) => e.id === entry.id);180 expect(updated?.passkey).toBeUndefined();181 expect(updated?.password).toBe("secret-1");182 });183184 it("accepts a complete passkey patch (field-by-field rebuild)", async () => {185 const { vault, entry } = await vaultWithEntry();186187 await vault.updateEntry(entry.id, { passkey: validPasskey });188189 const updated = vault.getEntries().find((e) => e.id === entry.id);190 expect(updated?.passkey?.rpId).toBe("example.com");191 expect(updated?.passkey?.credentialId).toBe("Y3JlZC0x");192 expect(updated?.passkey?.algorithm).toBe(-7);193 });194195 it("still removes a passkey via `passkey: undefined`", async () => {196 const { vault, entry } = await vaultWithEntry();197 await vault.updateEntry(entry.id, { passkey: validPasskey });198199 await vault.updateEntry(entry.id, { passkey: undefined });200201 const updated = vault.getEntries().find((e) => e.id === entry.id);202 expect(updated?.passkey).toBeUndefined();203 });204205 it("collapses a proto-pollution tags shape (non-array) to undefined", async () => {206 const { vault, entry } = await vaultWithEntry();207 await vault.updateEntry(entry.id, { tags: ["keep-me"] });208209 await vault.updateEntry(entry.id, {210 tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[],211 });212213 const updated = vault.getEntries().find((e) => e.id === entry.id);214 expect(updated?.tags).toBeUndefined();215 expect(({} as Record<string, unknown>).polluted).toBeUndefined();216 });217218 it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => {219 const { vault, entry } = await vaultWithEntry();220221 const oversized = "x".repeat(500);222 const many = Array.from({ length: 300 }, (_, i) => `tag-${i}`);223 await vault.updateEntry(entry.id, {224 tags: [oversized, 42, null, " ", ...many] as unknown as string[],225 });226227 const updated = vault.getEntries().find((e) => e.id === entry.id);228 expect(updated?.tags).toBeDefined();229 expect(updated!.tags!.length).toBeLessThanOrEqual(255);230 expect(updated!.tags![0]).toBe("x".repeat(256));231 expect(updated!.tags!.every((t) => typeof t === "string" && t.length <= 256)).toBe(true);232 });233});