123456789import { beforeEach, describe, expect, it, vi } from "vitest";10import type { PasskeyData, VaultEntry } from "../shared/types";1112type StorageShape = Record<string, unknown>;1314describe("PCH-02-001 — updateEntry patch sanitizers", () => {15 let storage: StorageShape;1617 beforeEach(() => {18 vi.resetModules();19 storage = {};2021 const chromeMock = {22 storage: {23 local: {24 get: vi.fn(async (key?: string | string[]) => {25 if (!key) return storage;26 if (typeof key === "string") {27 return { [key]: storage[key] };28 }29 return key.reduce<Record<string, unknown>>((result, entry) => {30 result[entry] = storage[entry];31 return result;32 }, {});33 }),34 set: vi.fn(async (next: Record<string, unknown>) => {35 Object.assign(storage, next);36 }),37 remove: vi.fn(async (keys: string | string[]) => {38 const list = Array.isArray(keys) ? keys : [keys];39 for (const key of list) delete storage[key];40 }),41 },42 },43 alarms: {44 clear: vi.fn(),45 create: vi.fn(),46 onAlarm: { addListener: vi.fn() },47 },48 idle: {49 setDetectionInterval: vi.fn(),50 onStateChanged: { addListener: vi.fn() },51 },52 } as unknown as typeof chrome;5354 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;5556 57 vi.stubGlobal("fetch", vi.fn(async () => {58 throw new Error("network disabled in tests");59 }));60 });6162 async function vaultWithEntry(extra: Partial<VaultEntry> = {}) {63 const vault = await import("./vault");64 await vault.createVault("correct horse battery staple");65 const entry = await vault.addEntry({66 title: "Example",67 username: "june",68 password: "secret-1",69 url: "https://login.example.com",70 ...extra,71 } as Parameters<typeof vault.addEntry>[0]);72 return { vault, entry };73 }7475 const validPasskey: PasskeyData = {76 rpId: "example.com",77 rpName: "Example",78 credentialId: "Y3JlZC0x",79 publicKeyCose: "cGstY29zZQ",80 userHandle: "dXNlci0x",81 userName: "june",82 userDisplayName: "June",83 algorithm: -7,84 signCount: 0,85 aaguid: "",86 backupEligible: false,87 backupState: false,88 createdAt: 1_700_000_000_000,89 };9091 it("strips a non-https canary webhook from an UPDATE_ENTRY patch", async () => {92 const { vault, entry } = await vaultWithEntry();9394 await vault.updateEntry(entry.id, {95 canary: { webhookUrl: "http://attacker.example.com/exfil" },96 });9798 const updated = vault.getEntries().find((e) => e.id === entry.id);99 expect(updated?.canary).toBeUndefined();100 });101102 it("preserves a valid https canary (and its bump fields) through a patch", async () => {103 const { vault, entry } = await vaultWithEntry();104105 106 await vault.updateEntry(entry.id, {107 canary: {108 webhookUrl: "https://legit.example.com/hook",109 label: "desk drawer",110 lastPinged: 1_700_000_111_000,111 pingCount: 3,112 },113 });114115 const updated = vault.getEntries().find((e) => e.id === entry.id);116 expect(updated?.canary).toEqual({117 webhookUrl: "https://legit.example.com/hook",118 label: "desk drawer",119 lastPinged: 1_700_000_111_000,120 pingCount: 3,121 });122 });123124 it("rebuilds the canary field-by-field — unknown keys are dropped", async () => {125 const { vault, entry } = await vaultWithEntry();126127 await vault.updateEntry(entry.id, {128 canary: {129 webhookUrl: "https://legit.example.com/hook",130 exfil: "smuggled",131 __proto__: { polluted: true },132 } as unknown as VaultEntry["canary"],133 });134135 const updated = vault.getEntries().find((e) => e.id === entry.id);136 expect(updated?.canary?.webhookUrl).toBe("https://legit.example.com/hook");137 138 const keys = Object.keys(updated!.canary!);139 expect(keys.every((k) => ["webhookUrl", "label", "lastPinged", "pingCount"].includes(k))).toBe(true);140 expect((updated!.canary as Record<string, unknown>).exfil).toBeUndefined();141 expect((updated!.canary as Record<string, unknown>).polluted).toBeUndefined();142 });143144 it("rejects a malformed passkey patch (missing credentialId) and keeps the entry unchanged", async () => {145 const { vault, entry } = await vaultWithEntry();146147 await expect(148 vault.updateEntry(entry.id, {149 passkey: {150 rpId: "example.com",151 152 publicKeyCose: "cGstY29zZQ",153 userHandle: "dXNlci0x",154 userName: "june",155 } as unknown as PasskeyData,156 }),157 ).rejects.toThrow("invalid passkey credential");158159 const updated = vault.getEntries().find((e) => e.id === entry.id);160 expect(updated?.passkey).toBeUndefined();161 expect(updated?.password).toBe("secret-1");162 });163164 it("accepts a complete passkey patch (field-by-field rebuild)", async () => {165 const { vault, entry } = await vaultWithEntry();166167 await vault.updateEntry(entry.id, { passkey: validPasskey });168169 const updated = vault.getEntries().find((e) => e.id === entry.id);170 expect(updated?.passkey?.rpId).toBe("example.com");171 expect(updated?.passkey?.credentialId).toBe("Y3JlZC0x");172 expect(updated?.passkey?.algorithm).toBe(-7);173 });174175 it("still removes a passkey via `passkey: undefined`", async () => {176 const { vault, entry } = await vaultWithEntry();177 await vault.updateEntry(entry.id, { passkey: validPasskey });178179 await vault.updateEntry(entry.id, { passkey: undefined });180181 const updated = vault.getEntries().find((e) => e.id === entry.id);182 expect(updated?.passkey).toBeUndefined();183 });184185 it("collapses a proto-pollution tags shape (non-array) to undefined", async () => {186 const { vault, entry } = await vaultWithEntry();187 await vault.updateEntry(entry.id, { tags: ["keep-me"] });188189 await vault.updateEntry(entry.id, {190 tags: JSON.parse('{"__proto__": ["evil"], "0": "fake"}') as unknown as string[],191 });192193 const updated = vault.getEntries().find((e) => e.id === entry.id);194 expect(updated?.tags).toBeUndefined();195 expect(({} as Record<string, unknown>).polluted).toBeUndefined();196 });197198 it("bounds patch tags: strings only, ≤256 chars each, ≤255 tags", async () => {199 const { vault, entry } = await vaultWithEntry();200201 const oversized = "x".repeat(500);202 const many = Array.from({ length: 300 }, (_, i) => `tag-${i}`);203 await vault.updateEntry(entry.id, {204 tags: [oversized, 42, null, " ", ...many] as unknown as string[],205 });206207 const updated = vault.getEntries().find((e) => e.id === entry.id);208 expect(updated?.tags).toBeDefined();209 expect(updated!.tags!.length).toBeLessThanOrEqual(255);210 expect(updated!.tags![0]).toBe("x".repeat(256));211 expect(updated!.tags!.every((t) => typeof t === "string" && t.length <= 256)).toBe(true);212 });213});