1import { beforeEach, describe, expect, it, vi } from "vitest";2import { decrypt } from "./aes";3import { deriveKey } from "../crypto/argon2";4import type { StoredVault } from "../shared/types";56type StorageShape = Record<string, unknown>;78describe("background vault", () => {9 let storage: StorageShape;10 let sessionStorage: StorageShape;11 let alarms = { created: [] as Array<{ name: string; delayInMinutes: number }>, cleared: [] as string[] };1213 beforeEach(() => {14 vi.resetModules();15 storage = {};16 sessionStorage = {};17 alarms = { created: [], cleared: [] };1819 const chromeMock = {20 storage: {21 local: {22 get: vi.fn(async (key?: string | string[]) => {23 if (!key) return storage;24 if (typeof key === "string") {25 return { [key]: storage[key] };26 }2728 return key.reduce<Record<string, unknown>>((result, entry) => {29 result[entry] = storage[entry];30 return result;31 }, {});32 }),33 set: vi.fn(async (next: Record<string, unknown>) => {34 Object.assign(storage, next);35 }),36 remove: vi.fn(async (keys: string | string[]) => {37 const list = Array.isArray(keys) ? keys : [keys];38 for (const key of list) {39 delete storage[key];40 }41 }),42 },43 session: {44 get: vi.fn(async (key?: string | string[]) => {45 if (!key) return sessionStorage;46 if (typeof key === "string") return { [key]: sessionStorage[key] };47 return key.reduce<Record<string, unknown>>((result, entry) => {48 result[entry] = sessionStorage[entry];49 return result;50 }, {});51 }),52 set: vi.fn(async (next: Record<string, unknown>) => {53 Object.assign(sessionStorage, next);54 }),55 remove: vi.fn(async (keys: string | string[]) => {56 const list = Array.isArray(keys) ? keys : [keys];57 for (const key of list) delete sessionStorage[key];58 }),59 clear: vi.fn(async () => {60 for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];61 }),62 },63 },64 alarms: {65 clear: vi.fn((name: string) => {66 alarms.cleared.push(name);67 }),68 create: vi.fn((name: string, info: { delayInMinutes: number }) => {69 alarms.created.push({ name, delayInMinutes: info.delayInMinutes });70 }),71 onAlarm: { addListener: vi.fn() },72 },73 idle: {74 setDetectionInterval: vi.fn(),75 onStateChanged: { addListener: vi.fn() },76 },77 } as unknown as typeof chrome;7879 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8081 82 vi.stubGlobal("fetch", vi.fn(async () => {83 throw new Error("network disabled in tests");84 }));85 });8687 it("creates a vault and stores encrypted payload", async () => {88 const vault = await import("./vault");8990 await vault.createVault("correct horse battery staple");9192 expect(storage.peach_vault_a).toBeDefined();93 expect((storage.peach_vault_a as StoredVault).ciphertext).toBeTruthy();94 expect(vault.getEntries()).toEqual([]);95 expect(alarms.created).toContainEqual({96 name: "autolock",97 delayInMinutes: 5,98 });99 });100101 it("writes both slots A and B on vault creation", async () => {102 const vault = await import("./vault");103 await vault.createVault("correct horse battery staple");104105 expect(storage.peach_vault_a).toBeDefined();106 expect(storage.peach_vault_b).toBeDefined();107 expect(storage.peach_duress).toBeUndefined();108 expect(storage.peach_vault).toBeUndefined();109 expect(storage.peach_decoy_vault).toBeUndefined();110 });111112 it("keeps the PIN wrapper in session storage and invalidates it on password change", async () => {113 const vault = await import("./vault");114 const password = "correct horse battery staple";115116 await vault.createVault(password);117 expect(await vault.setupPinUnlock(password, "123")).toBe(false);118 expect(await vault.setupPinUnlock(password, "1234567890123")).toBe(false);119 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);120 expect((sessionStorage.peach_pin_wrap as { ciphertext: string }).ciphertext).toBeTruthy();121 expect(storage.peach_pin_wrap).toBeUndefined();122123 vault.lock();124 expect(await vault.unlockWithPin("000000")).toBe(false);125 expect(await vault.unlockWithPin("1234")).toBe(true);126127 expect(await vault.changePassword(password, "new correct horse battery"))128 .toBe(true);129 vault.lock();130 expect(await vault.unlockWithPin("1234")).toBe(false);131 expect(await vault.unlock("new correct horse battery")).toBe(true);132 });133134 it("requires the master password after three incorrect PIN attempts", async () => {135 const vault = await import("./vault");136 const password = "correct horse battery staple";137138 await vault.createVault(password);139 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);140 vault.lock();141142 expect(await vault.unlockWithPin("0000")).toBe(false);143 expect(await vault.unlockWithPin("0000")).toBe(false);144 expect(await vault.unlockWithPin("0000")).toBe(false);145 expect(await vault.unlockWithPin("1234")).toBe(false);146 expect(await vault.unlock(password)).toBe(true);147148 vault.lock();149 expect(await vault.unlockWithPin("1234")).toBe(true);150 });151152 it("cannot unlock from a copied local-storage snapshot after session storage is cleared", async () => {153 const vault = await import("./vault");154 const password = "correct horse battery staple";155156 await vault.createVault(password);157 expect(await vault.setupPinUnlock(password, "1234")).toBe(true);158 const copiedLocalStorage = { ...storage };159 await (chrome.storage.session as typeof chrome.storage.session).clear();160 vault.lock();161162 expect(copiedLocalStorage.peach_pin_wrap).toBeUndefined();163 await expect(vault.unlockWithPin("1234")).resolves.toBe(false);164 await expect(vault.getPinUnlockStatus()).resolves.toMatchObject({165 configured: true,166 available: false,167 attemptsRemaining: 0,168 });169 });170171 it("slot A and slot B have matching ciphertext size at fresh install", async () => {172 const vault = await import("./vault");173 await vault.createVault("correct horse battery staple");174175 const a = storage.peach_vault_a as StoredVault;176 const b = storage.peach_vault_b as StoredVault;177 expect(a.ciphertext.length).toBe(b.ciphertext.length);178 });179180 it("does not expose a plaintext duress flag after setupDuress", async () => {181 const vault = await import("./vault");182 await vault.createVault("real-password-12345");183 await vault.setupDuress("decoy-password-67890");184185 expect(storage.peach_duress).toBeUndefined();186 expect(storage.peach_decoy_vault).toBeUndefined();187 const keys = Object.keys(storage).filter((k) => k.startsWith("peach_"));188 189 190 for (const key of keys) {191 expect(192 [193 "peach_vault_a",194 "peach_vault_b",195 "peach_settings",196 "peach_vault_history",197 "peach_last_modified_at",198 ]199 ).toContain(key);200 }201 expect(keys).toContain("peach_vault_a");202 expect(keys).toContain("peach_vault_b");203 });204205 it("unlocks with real password and loads real entries", async () => {206 const vault = await import("./vault");207 await vault.createVault("real-password-12345");208 await vault.addEntry({209 title: "GitHub",210 username: "june",211 password: "realsecret",212 url: "github.com",213 });214 vault.lock();215216 const ok = await vault.unlock("real-password-12345");217 expect(ok).toBe(true);218 expect(vault.getEntries()).toHaveLength(1);219 expect(vault.getEntries()[0].title).toBe("GitHub");220 });221222 it("unlocks with duress password and loads empty entries", async () => {223 const vault = await import("./vault");224 await vault.createVault("real-password-12345");225 await vault.addEntry({226 title: "GitHub",227 username: "june",228 password: "realsecret",229 url: "github.com",230 });231 await vault.setupDuress("decoy-password-67890");232 vault.lock();233234 const ok = await vault.unlock("decoy-password-67890");235 expect(ok).toBe(true);236 expect(vault.getEntries()).toEqual([]);237 });238239 it("HOLE 5: the duress vault has no sync identity and cannot sync", async () => {240 const vault = await import("./vault");241 await vault.createVault("real-password-12345");242 await vault.addEntry({ type: "login", title: "GitHub", password: "realsecret" });243 await vault.setupDuress("decoy-password-67890");244245 246 expect(vault.getRootKeyHex()).toBeTruthy();247 vault.lock();248249 expect(await vault.unlock("decoy-password-67890")).toBe(true);250 251 252 253 expect(vault.getRootKeyHex()).toBeNull();254255 256 257 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");258 const adapter = createSyncVaultAdapter();259 await expect(adapter.computeVaultId()).rejects.toThrow();260 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toThrow();261 vault.lock();262 });263264 it("runs both slot derivations in parallel for constant-time unlock (DUR-002)", async () => {265 266 267 268 269 const argon2 = await import("../crypto/argon2");270 const deriveSpy = vi.spyOn(argon2, "deriveKey");271272 const vault = await import("./vault");273 await vault.createVault("real-password-12345");274 await vault.setupDuress("decoy-password-67890");275 vault.lock();276277 deriveSpy.mockClear();278 await vault.unlock("real-password-12345");279 expect(deriveSpy).toHaveBeenCalledTimes(2);280 vault.lock();281282 deriveSpy.mockClear();283 await vault.unlock("decoy-password-67890");284 expect(deriveSpy).toHaveBeenCalledTimes(2);285 vault.lock();286287 deriveSpy.mockClear();288 await vault.unlock("wrong-password-xxxxx");289 expect(deriveSpy).toHaveBeenCalledTimes(2);290291 deriveSpy.mockRestore();292 });293294 it("rejects wrong passwords", async () => {295 const vault = await import("./vault");296 await vault.createVault("real-password-12345");297 vault.lock();298299 const ok = await vault.unlock("nope-nope-nope");300 expect(ok).toBe(false);301 });302303 it("persists duressConfigured across lock/unlock in encrypted payload", async () => {304 const vault = await import("./vault");305 await vault.createVault("real-password-12345");306 await vault.setupDuress("decoy-password-67890");307 vault.lock();308309 await vault.unlock("real-password-12345");310 expect(await vault.isDuressEnabled()).toBe(true);311312 await vault.disableDuress();313 expect(await vault.isDuressEnabled()).toBe(false);314 vault.lock();315316 await vault.unlock("real-password-12345");317 expect(await vault.isDuressEnabled()).toBe(false);318 });319320 it("soft deletes entries while keeping deletedAt in encrypted storage", async () => {321 const vault = await import("./vault");322323 await vault.createVault("correct horse battery staple");324 const entry = await vault.addEntry({325 title: "GitHub",326 username: "june",327 password: "secret123",328 url: "github.com",329 });330331 await vault.deleteEntry(entry.id);332333 expect(vault.getEntries()).toEqual([]);334335 const stored = storage.peach_vault_a as StoredVault;336 const salt = Uint8Array.from(337 stored.salt.match(/.{1,2}/g)!.map((value: string) => parseInt(value, 16))338 );339 const key = await deriveKey("correct horse battery staple", salt);340 const plain = await decrypt(key, stored.nonce, stored.ciphertext, stored.tag, "slot_a_v1");341 const parsed = JSON.parse(plain) as { entries: Array<{ id: string; deletedAt?: number }> };342343 expect(parsed.entries).toHaveLength(1);344 expect(parsed.entries[0].id).toBe(entry.id);345 expect(typeof parsed.entries[0].deletedAt).toBe("number");346 });347348 it("accepts imported entries without a username or url", async () => {349 const vault = await import("./vault");350351 await vault.createVault("correct horse battery staple");352 const entry = await vault.addEntry({353 title: "Imported Login",354 username: "",355 password: "secret123",356 url: "",357 notes: "migrated from another manager",358 });359360 expect(entry.username).toBe("");361 expect(entry.url).toBe("");362 expect(vault.getEntries()).toHaveLength(1);363 });364365 it("changePassword happy path — new password unlocks, old is rejected", async () => {366 const vault = await import("./vault");367368 await vault.createVault("old-pass-abc123");369 await vault.addEntry({ title: "GitHub", username: "june", password: "secret1", url: "github.com" });370 await vault.addEntry({ title: "Gmail", username: "june", password: "secret2", url: "mail.google.com" });371372 expect(await vault.changePassword("old-pass-abc123", "new-pass-xyz789")).toBe(true);373374 vault.lock();375 expect(await vault.unlock("new-pass-xyz789")).toBe(true);376 expect(vault.getEntries()).toHaveLength(2);377378 vault.lock();379 expect(await vault.unlock("old-pass-abc123")).toBe(false);380 });381382 it("stamps peach_last_modified_at on createVault", async () => {383 const vault = await import("./vault");384 const before = Date.now();385 await new Promise((resolve) => setTimeout(resolve, 1));386387 await vault.createVault("stamp-create-pass");388389 expect(Number(storage.peach_last_modified_at)).toBeGreaterThanOrEqual(before);390 });391392 it("stamps peach_last_modified_at on entry mutations through persistEntries", async () => {393 const vault = await import("./vault");394 await vault.createVault("stamp-entry-pass");395396 let previous = Number(storage.peach_last_modified_at);397 await new Promise((resolve) => setTimeout(resolve, 1));398 const entry = await vault.addEntry({399 title: "Stamp",400 username: "",401 password: "p",402 url: "",403 });404 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);405406 previous = Number(storage.peach_last_modified_at);407 await new Promise((resolve) => setTimeout(resolve, 1));408 await vault.updateEntry(entry.id, { title: "Stamped" });409 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);410411 previous = Number(storage.peach_last_modified_at);412 await new Promise((resolve) => setTimeout(resolve, 1));413 await vault.deleteEntry(entry.id);414 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);415 });416417 it("stamps peach_last_modified_at on setupDuress and changePassword", async () => {418 const vault = await import("./vault");419 await vault.createVault("stamp-real-pass");420421 let previous = Number(storage.peach_last_modified_at);422 await new Promise((resolve) => setTimeout(resolve, 1));423 await vault.setupDuress("stamp-decoy-pass");424 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);425426 previous = Number(storage.peach_last_modified_at);427 await new Promise((resolve) => setTimeout(resolve, 1));428 expect(await vault.changePassword("stamp-real-pass", "stamp-new-pass")).toBe(true);429 expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);430 });431432 it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {433 const vault = await import("./vault");434435 await vault.createVault("correct-pass-abcde");436 const slotABefore = JSON.stringify(storage.peach_vault_a);437438 expect(await vault.changePassword("wrong-pass", "new-pass")).toBe(false);439 expect(JSON.stringify(storage.peach_vault_a)).toBe(slotABefore);440441 vault.lock();442 expect(await vault.unlock("correct-pass-abcde")).toBe(true);443 });444445 it("changePassword with duress configured leaves slot B byte-identical", async () => {446 const vault = await import("./vault");447448 await vault.createVault("real-pass-abcde");449 await vault.setupDuress("duress-pass-xyzzz");450451 const slotBBefore = JSON.stringify(storage.peach_vault_b);452 expect(await vault.changePassword("real-pass-abcde", "new-real-pass-fghij")).toBe(true);453 expect(JSON.stringify(storage.peach_vault_b)).toBe(slotBBefore);454455 456 vault.lock();457 expect(await vault.unlock("duress-pass-xyzzz")).toBe(true);458 expect(vault.getEntries()).toEqual([]);459 });460461 it("restoreEntries with mode 'replace' and empty incoming throws and leaves entries intact", async () => {462 const vault = await import("./vault");463464 await vault.createVault("pass-abcdef");465 await vault.addEntry({ title: "One", username: "", password: "1", url: "" });466 await vault.addEntry({ title: "Two", username: "", password: "2", url: "" });467 await vault.addEntry({ title: "Three", username: "", password: "3", url: "" });468469 await expect(vault.restoreEntries([], "replace")).rejects.toThrow(470 /refuse to replace with empty entry set/i471 );472 expect(vault.getEntries()).toHaveLength(3);473 });474475 it("changePassword swaps the active key before Slot B is regenerated", async () => {476 const vault = await import("./vault");477478 await vault.createVault("correct-pass-12345");479 const entry = await vault.addEntry({480 title: "GitHub",481 username: "june",482 password: "secret123",483 url: "https://github.com",484 });485486 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;487 const originalSet = setMock.getMockImplementation();488 let injectedDuringSlotBWrite = false;489490 setMock.mockImplementation(async (next: Record<string, unknown>) => {491 Object.assign(storage, next);492493 if (!injectedDuringSlotBWrite && "peach_vault_b" in next) {494 injectedDuringSlotBWrite = true;495 await vault.updateEntry(entry.id, { notes: "updated during password change" });496 }497 });498499 expect(await vault.changePassword("correct-pass-12345", "new-pass-67890")).toBe(true);500501 setMock.mockImplementation(originalSet ?? (async (next: Record<string, unknown>) => {502 Object.assign(storage, next);503 }));504505 vault.lock();506 expect(await vault.unlock("new-pass-67890")).toBe(true);507 expect(vault.getEntryById(entry.id)?.notes).toBe("updated during password change");508509 vault.lock();510 expect(await vault.unlock("correct-pass-12345")).toBe(false);511 });512513 it("restoreEntries replace mode replaces existing entries", async () => {514 const vault = await import("./vault");515516 await vault.createVault("replace-pass-12345");517 await vault.addEntry({ title: "A", username: "june", password: "one", url: "https://a.test" });518 await vault.addEntry({ title: "B", username: "june", password: "two", url: "https://b.test" });519520 const incoming = [521 {522 id: "entry-c",523 type: "login" as const,524 title: "C",525 username: "june",526 password: "three",527 url: "https://c.test",528 createdAt: 10,529 modifiedAt: 10,530 },531 {532 id: "entry-d",533 type: "login" as const,534 title: "D",535 username: "june",536 password: "four",537 url: "https://d.test",538 createdAt: 20,539 modifiedAt: 20,540 },541 ];542543 await expect(vault.restoreEntries(incoming, "replace")).resolves.toEqual({544 added: 2,545 updated: 0,546 skipped: 0,547 });548549 expect(vault.getEntries().map((entry) => entry.id)).toEqual(["entry-c", "entry-d"]);550 });551552 it("restoreEntries merge mode lets newer incoming entries win", async () => {553 const vault = await import("./vault");554555 await vault.createVault("merge-pass-12345");556 const existing = await vault.addEntry({557 title: "GitHub",558 username: "june",559 password: "old-secret",560 url: "https://github.com",561 });562563 await expect(564 vault.restoreEntries(565 [566 {567 ...existing,568 password: "new-secret",569 modifiedAt: existing.modifiedAt + 100,570 },571 ],572 "merge"573 )574 ).resolves.toEqual({ added: 0, updated: 1, skipped: 0 });575576 expect(vault.getEntryById(existing.id)?.password).toBe("new-secret");577 });578579 it("restoreEntries merge mode keeps newer local entries when incoming is older", async () => {580 const vault = await import("./vault");581582 await vault.createVault("merge-pass-67890");583 const existing = await vault.addEntry({584 title: "GitHub",585 username: "june",586 password: "current-secret",587 url: "https://github.com",588 });589590 await expect(591 vault.restoreEntries(592 [593 {594 ...existing,595 password: "stale-secret",596 modifiedAt: existing.modifiedAt - 100,597 },598 ],599 "merge"600 )601 ).resolves.toEqual({ added: 0, updated: 0, skipped: 1 });602603 expect(vault.getEntryById(existing.id)?.password).toBe("current-secret");604 });605606 it("clearVaultEntries without explicit confirmation throws", async () => {607 const vault = await import("./vault");608609 await vault.createVault("clear-pass-12345");610 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });611612 const clearVaultEntries = vault.clearVaultEntries as unknown as (613 confirmation?: unknown614 ) => Promise<void>;615616 await expect(clearVaultEntries()).rejects.toThrow(/explicit confirmation/i);617 await expect(clearVaultEntries("wrong")).rejects.toThrow(/explicit confirmation/i);618 expect(vault.getEntries()).toHaveLength(1);619 });620621 it("clearVaultEntries with REPLACE_ALL soft-deletes all entries", async () => {622 const vault = await import("./vault");623624 await vault.createVault("clear-pass-67890");625 await vault.addEntry({ title: "A", username: "", password: "one", url: "" });626 await vault.addEntry({ title: "B", username: "", password: "two", url: "" });627 await vault.addEntry({ title: "C", username: "", password: "three", url: "" });628629 await vault.clearVaultEntries("REPLACE_ALL");630631 expect(vault.getEntries()).toEqual([]);632 expect(vault.getDeletedEntries()).toHaveLength(3);633 expect(vault.getDeletedEntries().every((entry) => typeof entry.deletedAt === "number")).toBe(true);634 });635636 it("deduplicateEntries throws in decoy mode", async () => {637 const vault = await import("./vault");638639 await vault.createVault("real-pass-12345");640 await vault.addEntry({641 title: "GitHub",642 username: "june",643 password: "secret123",644 url: "https://github.com",645 });646 await vault.setupDuress("duress-pass-12345");647 vault.lock();648649 expect(await vault.unlock("duress-pass-12345")).toBe(true);650 await expect(vault.deduplicateEntries()).rejects.toThrow(/decoy mode/i);651 });652653 it("deduplicateEntries removes older duplicates in real mode", async () => {654 const vault = await import("./vault");655656 await vault.createVault("dedupe-pass-12345");657 await vault.addEntry({658 title: "GitHub",659 username: "june",660 password: "old-secret",661 url: "https://github.com",662 });663 await new Promise<void>((resolve) => setTimeout(resolve, 5));664 const newer = await vault.addEntry({665 title: "GitHub",666 username: "june",667 password: "new-secret",668 url: "https://github.com",669 });670671 expect(await vault.deduplicateEntries()).toBe(1);672 expect(vault.getEntries()).toHaveLength(1);673 expect(vault.getEntries()[0].id).toBe(newer.id);674 expect(vault.getDeletedEntries()).toHaveLength(1);675 });676677 it("updateEntry with an empty password on a login entry clears the password", async () => {678 const vault = await import("./vault");679680 await vault.createVault("update-pass-12345");681 const entry = await vault.addEntry({682 title: "GitHub",683 username: "june",684 password: "secret123",685 url: "https://github.com",686 });687688 await expect(vault.updateEntry(entry.id, { password: "" })).resolves.toBeUndefined();689 expect(vault.getEntryById(entry.id)?.password).toBeUndefined();690 });691692 it("updateEntry with an empty password on a note entry is allowed", async () => {693 const vault = await import("./vault");694695 await vault.createVault("note-pass-12345");696 const entry = await vault.addEntry({697 type: "note",698 title: "Secure note",699 notes: "keep me",700 });701702 await expect(vault.updateEntry(entry.id, { password: "", notes: "updated" })).resolves.toBeUndefined();703 expect(vault.getEntryById(entry.id)).toMatchObject({704 type: "note",705 notes: "updated",706 });707 });708709 it("lock zeroes the derived key and resets unlock state", async () => {710 const aes = await import("./aes");711 const zeroKeySpy = vi.spyOn(aes, "zeroKey");712 const vault = await import("./vault");713714 await vault.createVault("lock-pass-12345");715 expect(vault.isUnlocked()).toBe(true);716717 vault.lock();718719 expect(zeroKeySpy).toHaveBeenCalled();720 expect(vault.isUnlocked()).toBe(false);721 zeroKeySpy.mockRestore();722 });723724 it("lock clears in-memory entries", async () => {725 const vault = await import("./vault");726727 await vault.createVault("lock-pass-67890");728 const entry = await vault.addEntry({729 title: "GitHub",730 username: "june",731 password: "secret123",732 url: "https://github.com",733 });734735 expect(vault.getEntryById(entry.id)?.title).toBe("GitHub");736 vault.lock();737 expect(vault.getEntryById(entry.id)).toBeUndefined();738 });739740 it("unlock with neither slot decryptable returns false and leaves the vault locked", async () => {741 const vault = await import("./vault");742743 await vault.createVault("real-pass-67890");744 vault.lock();745746 expect(await vault.unlock("totally-wrong-password")).toBe(false);747 expect(vault.isUnlocked()).toBe(false);748 });749750 it("isUnlocked tracks the full lock lifecycle", async () => {751 const vault = await import("./vault");752753 expect(vault.isUnlocked()).toBe(false);754 await vault.createVault("unlock-pass-12345");755 expect(vault.isUnlocked()).toBe(true);756 vault.lock();757 expect(vault.isUnlocked()).toBe(false);758 expect(await vault.unlock("unlock-pass-12345")).toBe(true);759 expect(vault.isUnlocked()).toBe(true);760 vault.lock();761 expect(vault.isUnlocked()).toBe(false);762 });763764 it("persistEntries surfaces STORAGE_QUOTA_EXCEEDED when slot writes hit quota", async () => {765 const vault = await import("./vault");766767 await vault.createVault("quota-pass-12345");768769 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;770 setMock.mockImplementation(async (next: Record<string, unknown>) => {771 if ("peach_vault_a" in next || "peach_vault_b" in next) {772 throw new Error("QUOTA_BYTES quota reached");773 }774 Object.assign(storage, next);775 });776777 await expect(778 vault.addEntry({779 title: "GitHub",780 username: "june",781 password: "secret123",782 url: "https://github.com",783 })784 ).rejects.toThrow("STORAGE_QUOTA_EXCEEDED");785 });786787 it("persistSettings surfaces STORAGE_QUOTA_EXCEEDED when settings writes hit quota", async () => {788 const vault = await import("./vault");789790 await vault.createVault("settings-pass-12345");791792 const setMock = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;793 setMock.mockImplementation(async (next: Record<string, unknown>) => {794 if ("peach_settings" in next) {795 throw new Error("QUOTA_BYTES quota reached");796 }797 Object.assign(storage, next);798 });799800 await expect(vault.updateSettings({ autoLockMinutes: 10 })).rejects.toThrow(801 "STORAGE_QUOTA_EXCEEDED"802 );803 });804805 it("card entry round-trips through vault — all fields preserved", async () => {806 const vault = await import("./vault");807808 await vault.createVault("card-test-pass-12345");809 const entry = await vault.addEntry({810 type: "card",811 title: "Personal Visa",812 cardholderName: "Jane Smith",813 cardNumber: "4111111111111111",814 expMonth: "05",815 expYear: "2028",816 cvc: "123",817 cardNickname: "Personal Visa",818 network: "visa",819 last4: "1111",820 });821822 expect(entry.type).toBe("card");823 expect(entry.cardholderName).toBe("Jane Smith");824 expect(entry.cardNumber).toBe("4111111111111111");825 expect(entry.expMonth).toBe("05");826 expect(entry.expYear).toBe("2028");827 expect(entry.cvc).toBe("123");828 expect(entry.network).toBe("visa");829 expect(entry.last4).toBe("1111");830831 vault.lock();832 await vault.unlock("card-test-pass-12345");833 const entries = vault.getEntries();834 expect(entries).toHaveLength(1);835 expect(entries[0].cardNumber).toBe("4111111111111111");836 expect(entries[0].cvc).toBe("123");837 });838839 it("updateEntry — cannot clear card number on card entry", async () => {840 const vault = await import("./vault");841842 await vault.createVault("card-guard-pass-12345");843 const entry = await vault.addEntry({844 type: "card",845 title: "My Card",846 cardholderName: "Jane Smith",847 cardNumber: "4111111111111111",848 expMonth: "05",849 expYear: "2028",850 cvc: "123",851 });852853 await expect(854 vault.updateEntry(entry.id, { cardNumber: "" })855 ).rejects.toThrow("card number is required for card entries");856857 const entries = vault.getEntries();858 expect(entries[0].cardNumber).toBe("4111111111111111");859 });860861 it("declineCardSaveOffer — stores fingerprint in settings", async () => {862 const vault = await import("./vault");863864 await vault.createVault("decline-pass-12345");865 await vault.declineCardSaveOffer("1111", "05", "2028");866867 const settings = vault.getSettings();868 expect(settings.declinedCardFingerprints).toHaveLength(1);869 });870871 it("isCardDeclined — returns true for previously declined card", async () => {872 const vault = await import("./vault");873874 await vault.createVault("declined-check-pass-12345");875 await vault.declineCardSaveOffer("1111", "05", "2028");876877 expect(await vault.isCardDeclined("1111", "05", "2028")).toBe(true);878 });879880 it("isCardDeclined — returns false for card with same last4 but different expiry", async () => {881 const vault = await import("./vault");882883 await vault.createVault("declined-diff-pass-12345");884 await vault.declineCardSaveOffer("1111", "05", "2028");885886 expect(await vault.isCardDeclined("1111", "06", "2028")).toBe(false);887 });888889 it("queryDomain matches an entry against its additionalUrls", async () => {890 const vault = await import("./vault");891892 await vault.createVault("multi-domain-pass-12345");893 const entry = await vault.addEntry({894 type: "login",895 title: "Foo",896 username: "june",897 password: "secret",898 url: "https://foo.com",899 additionalUrls: ["https://bar.io", "https://baz.org"],900 });901902 903 904 expect(vault.queryDomain("foo.com").map((e) => e.id)).toContain(entry.id);905 expect(vault.queryDomain("bar.io").map((e) => e.id)).toContain(entry.id);906 expect(vault.queryDomain("baz.org").map((e) => e.id)).toContain(entry.id);907 expect(vault.queryDomain("unrelated.com")).toEqual([]);908 });909910 it("updateEntry trims, dedupes, and clears empty additionalUrls", async () => {911 const vault = await import("./vault");912913 await vault.createVault("dedupe-additional-pass-12345");914 const entry = await vault.addEntry({915 type: "login",916 title: "Foo",917 username: "june",918 password: "secret",919 url: "https://foo.com",920 });921922 await vault.updateEntry(entry.id, {923 additionalUrls: [924 " https://login.foo.com ",925 "https://login.foo.com",926 "",927 " ",928 "https://accounts.foo.com",929 ],930 });931932 const stored = vault.getEntryById(entry.id);933 expect(stored?.additionalUrls).toEqual([934 "https://login.foo.com",935 "https://accounts.foo.com",936 ]);937938 await vault.updateEntry(entry.id, { additionalUrls: ["", " "] });939 expect(vault.getEntryById(entry.id)?.additionalUrls).toBeUndefined();940 });941942 it("preserves email field through lock/unlock cycle", async () => {943 const vault = await import("./vault");944 await vault.createVault("preserve-email-pass-12345");945 await vault.addEntry({946 type: "login",947 title: "GitHub",948 username: "june",949 email: "[email protected]",950 password: "secret",951 url: "https://github.com",952 });953 vault.lock();954955 const ok = await vault.unlock("preserve-email-pass-12345");956 expect(ok).toBe(true);957 const entry = vault.getEntries().find((e) => e.title === "GitHub");958 expect(entry?.email).toBe("[email protected]");959 });960961 it("preserves additionalUrls through lock/unlock cycle", async () => {962 const vault = await import("./vault");963 await vault.createVault("preserve-urls-pass-12345");964 await vault.addEntry({965 type: "login",966 title: "Example",967 username: "june",968 password: "secret",969 url: "https://example.com",970 additionalUrls: [971 "https://beta.example.com",972 "https://staging.example.com",973 ],974 });975 vault.lock();976977 const ok = await vault.unlock("preserve-urls-pass-12345");978 expect(ok).toBe(true);979 const entry = vault.getEntries().find((e) => e.title === "Example");980 expect(entry?.additionalUrls).toEqual([981 "https://beta.example.com",982 "https://staging.example.com",983 ]);984 });985986 it("normalizeEntry preserves passwordChangedAt through lock/unlock", async () => {987 const vault = await import("./vault");988 await vault.createVault("pwchanged-preserve-pass");989 const entry = await vault.addEntry({990 title: "Aged",991 username: "june",992 password: "secret",993 url: "https://aged.test",994 });995 996 997 const ts = entry.passwordChangedAt;998 expect(typeof ts).toBe("number");999 vault.lock();10001001 expect(await vault.unlock("pwchanged-preserve-pass")).toBe(true);1002 const restored = vault.getEntries().find((e) => e.id === entry.id);1003 expect(restored?.passwordChangedAt).toBe(ts);1004 });10051006 it("normalizeEntry handles entries without passwordChangedAt", async () => {1007 const vault = await import("./vault");1008 await vault.createVault("pwchanged-absent-pass");1009 1010 const note = await vault.addEntry({ type: "note", title: "Memo", notes: "x" });1011 vault.lock();10121013 expect(await vault.unlock("pwchanged-absent-pass")).toBe(true);1014 const restored = vault.getEntries().find((e) => e.id === note.id);1015 expect(restored?.passwordChangedAt).toBeUndefined();1016 });10171018 it("applyMergedRemoteEntries does not advance the vault version", async () => {1019 const vault = await import("./vault");1020 await vault.createVault("syncver-pass-aaaaa");1021 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10221023 const before = await vault.getVaultVersion();1024 await new Promise((r) => setTimeout(r, 2));1025 await vault.applyMergedRemoteEntries(1026 [1027 {1028 id: "remote-1",1029 type: "login",1030 title: "Remote",1031 username: "r",1032 password: "x",1033 url: "https://r.test",1034 createdAt: Date.now(),1035 modifiedAt: Date.now(),1036 },1037 ],1038 {},1039 );10401041 expect(await vault.getVaultVersion()).toBe(before);1042 });10431044 it("addEntry advances the vault version", async () => {1045 const vault = await import("./vault");1046 await vault.createVault("syncver-pass-bbbbb");10471048 const before = await vault.getVaultVersion();1049 await new Promise((r) => setTimeout(r, 2));1050 await vault.addEntry({ title: "Local", username: "june", password: "p", url: "https://l.test" });10511052 expect(await vault.getVaultVersion()).toBeGreaterThan(before);1053 });10541055 it("modifiedAt is monotonic even when the wall clock regresses (SEV-2-E)", async () => {1056 const vault = await import("./vault");1057 await vault.createVault("monotonic-pass-aaaaa");1058 const e1 = await vault.addEntry({ title: "M", username: "june", password: "p1", url: "https://m.test" });1059 const before = vault.getEntryById(e1.id)!.modifiedAt;10601061 1062 const spy = vi.spyOn(Date, "now").mockReturnValue(before - 10_000);1063 await vault.updateEntry(e1.id, { password: "p2" });1064 const after = vault.getEntryById(e1.id)!.modifiedAt;1065 expect(after).toBeGreaterThan(before);1066 spy.mockRestore();1067 });10681069 it("deletedAt is strictly greater than the entry's modifiedAt", async () => {1070 const vault = await import("./vault");1071 await vault.createVault("monotonic-pass-bbbbb");1072 const e = await vault.addEntry({ title: "D", username: "june", password: "p", url: "https://d.test" });1073 const modAt = vault.getEntryById(e.id)!.modifiedAt;1074 await vault.deleteEntry(e.id);1075 const tomb = vault.getDeletedEntries().find((t) => t.id === e.id)!;1076 expect(tomb.deletedAt!).toBeGreaterThan(modAt);1077 });10781079 it("updateEntry rolls back in-memory state when the disk write fails (SEV-3-A)", async () => {1080 const vault = await import("./vault");1081 await vault.createVault("rollback-pass-aaaaa");1082 const e = await vault.addEntry({ title: "R", username: "june", password: "original", url: "https://r.test" });10831084 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1085 1086 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1087 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1088 Object.assign(storage, next);1089 });10901091 await expect(vault.updateEntry(e.id, { password: "changed" })).rejects.toThrow();10921093 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1094 1095 expect(vault.getEntryById(e.id)!.password).toBe("original");1096 });10971098 it("getRootKeyHex is stable across a password change (SEV-2-B)", async () => {1099 const vault = await import("./vault");1100 await vault.createVault("rootkey-pass-aaaaa");1101 const before = vault.getRootKeyHex();1102 expect(before).toMatch(/^[0-9a-f]{64}$/);11031104 expect(await vault.changePassword("rootkey-pass-aaaaa", "rootkey-pass-bbbbb")).toBe(true);1105 expect(vault.getRootKeyHex()).toBe(before);11061107 1108 vault.lock();1109 expect(await vault.unlock("rootkey-pass-bbbbb")).toBe(true);1110 expect(vault.getRootKeyHex()).toBe(before);1111 });11121113 it("getRootKeyHex returns null when the vault is locked", async () => {1114 const vault = await import("./vault");1115 await vault.createVault("rootkey-pass-ccccc");1116 vault.lock();1117 expect(vault.getRootKeyHex()).toBeNull();1118 });11191120 it("unlock_with_key_rehydrates_root_key (PCH-01-009)", async () => {1121 const vault = await import("./vault");1122 await vault.createVault("biometric-pass-12345");1123 const rootBefore = vault.getRootKeyHex();1124 const keyHex = vault.getDerivedKeyHex();1125 expect(rootBefore).not.toBeNull();1126 vault.lock();1127 1128 const r = await vault.unlockWithKey(keyHex);1129 expect(r.ok).toBe(true);1130 expect(vault.getRootKeyHex()).toBe(rootBefore);1131 });11321133 it("duress keeps both slots equal length at rest, even as the real vault grows (audit MEDIUM)", async () => {1134 const vault = await import("./vault");1135 await vault.createVault("duress-real-pass-aaaa");1136 await vault.setupDuress("duress-decoy-pass-bbbb");11371138 const lenA = () => (storage.peach_vault_a as StoredVault).ciphertext.length;1139 const lenB = () => (storage.peach_vault_b as StoredVault).ciphertext.length;11401141 1142 expect(lenA()).toBe(lenB());11431144 1145 1146 await vault.addEntry({ title: "One", username: "u", password: "p", url: "https://a.test" });1147 await vault.addEntry({ title: "Two", username: "u", password: "p", url: "https://b.test" });1148 expect(lenA()).toBe(lenB());1149 });11501151 it("deleteEntry rolls back the tombstone when the disk write fails (SEV-3-A)", async () => {1152 const vault = await import("./vault");1153 await vault.createVault("rollback-pass-bbbbb");1154 const e = await vault.addEntry({ title: "R", username: "june", password: "p", url: "https://r.test" });11551156 const setSpy = chrome.storage.local.set as unknown as ReturnType<typeof vi.fn>;1157 setSpy.mockImplementation(async (next: Record<string, unknown>) => {1158 if ("peach_vault_a" in next) throw new Error("QUOTA_BYTES quota exceeded");1159 Object.assign(storage, next);1160 });11611162 await expect(vault.deleteEntry(e.id)).rejects.toThrow();11631164 setSpy.mockImplementation(async (next: Record<string, unknown>) => { Object.assign(storage, next); });1165 1166 expect(vault.getEntryById(e.id)).toBeDefined();1167 expect(vault.getDeletedEntries().find((t) => t.id === e.id)).toBeUndefined();1168 });11691170 describe("password history", () => {1171 it("captures the old password on change, newest first", async () => {1172 const vault = await import("./vault");1173 await vault.createVault("history-pass-12345");1174 const e = await vault.addEntry({ title: "H", username: "june", password: "first", url: "https://h.test" });1175 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();11761177 await vault.updateEntry(e.id, { password: "second" });1178 await vault.updateEntry(e.id, { password: "third" });11791180 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1181 expect(hist.map((h) => h.password)).toEqual(["second", "first"]);1182 expect(vault.getEntryById(e.id)?.password).toBe("third");1183 });11841185 it("does not record history on a metadata-only update", async () => {1186 const vault = await import("./vault");1187 await vault.createVault("history-meta-12345");1188 const e = await vault.addEntry({ title: "M", username: "june", password: "keepme", url: "https://m.test" });11891190 await vault.updateEntry(e.id, { title: "Renamed" });1191 await vault.updateEntry(e.id, { username: "newuser" });11921193 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1194 });11951196 it("records each history item with the age the password carried", async () => {1197 const vault = await import("./vault");1198 await vault.createVault("history-stamp-12345");1199 const e = await vault.addEntry({ title: "S", username: "june", password: "v1", url: "https://s.test" });1200 const firstChangedAt = vault.getEntryById(e.id)!.passwordChangedAt;1201 expect(firstChangedAt).toBeGreaterThan(0);12021203 await new Promise((resolve) => setTimeout(resolve, 2));1204 await vault.updateEntry(e.id, { password: "v2" });12051206 const hist = vault.getEntryById(e.id)!.passwordHistory!;1207 1208 expect(hist[0].changedAt).toBe(firstChangedAt);1209 });12101211 it("skips capture when re-saving the identical password", async () => {1212 const vault = await import("./vault");1213 await vault.createVault("history-noop-12345");1214 const e = await vault.addEntry({ title: "N", username: "june", password: "same", url: "https://n.test" });12151216 await vault.updateEntry(e.id, { password: "same" });12171218 expect(vault.getEntryById(e.id)?.passwordHistory).toBeUndefined();1219 });12201221 it("does not capture an empty prior password (clear then set)", async () => {1222 const vault = await import("./vault");1223 await vault.createVault("history-empty-12345");1224 const e = await vault.addEntry({ title: "E", username: "june", password: "", url: "https://e.test" });12251226 await vault.updateEntry(e.id, { password: "now-has-one" });12271228 1229 expect(vault.getEntryById(e.id)?.passwordHistory ?? []).toHaveLength(0);1230 });12311232 it("restoring an old password re-records the swapped-out current one", async () => {1233 const vault = await import("./vault");1234 await vault.createVault("history-restore-12345");1235 const e = await vault.addEntry({ title: "R", username: "june", password: "old", url: "https://r.test" });1236 await vault.updateEntry(e.id, { password: "new" });12371238 1239 await vault.updateEntry(e.id, { password: "old" });12401241 const hist = vault.getEntryById(e.id)!.passwordHistory!;1242 expect(vault.getEntryById(e.id)?.password).toBe("old");1243 expect(hist.map((h) => h.password)).toEqual(["new", "old"]);1244 });12451246 it("preserves history across lock/unlock (normalizeEntry whitelist)", async () => {1247 const vault = await import("./vault");1248 await vault.createVault("history-persist-12345");1249 const e = await vault.addEntry({ title: "P", username: "june", password: "p1", url: "https://p.test" });1250 await vault.updateEntry(e.id, { password: "p2" });12511252 vault.lock();1253 expect(await vault.unlock("history-persist-12345")).toBe(true);12541255 const hist = vault.getEntryById(e.id)?.passwordHistory ?? [];1256 expect(hist.map((h) => h.password)).toEqual(["p1"]);1257 });12581259 it("caps history at 50 entries, dropping the oldest", async () => {1260 const vault = await import("./vault");1261 await vault.createVault("history-cap-12345");1262 const e = await vault.addEntry({ title: "C", username: "june", password: "pw-0", url: "https://c.test" });12631264 for (let i = 1; i <= 55; i++) {1265 await vault.updateEntry(e.id, { password: `pw-${i}` });1266 }12671268 const hist = vault.getEntryById(e.id)!.passwordHistory!;1269 expect(hist).toHaveLength(50);1270 1271 expect(hist[0].password).toBe("pw-54");1272 expect(hist[hist.length - 1].password).toBe("pw-5");1273 });1274 });12751276 describe("MV-6 import field whitelist (sanitizeRestoredEntry)", () => {1277 it("drops unknown/attacker keys from a restored entry", async () => {1278 const vault = await import("./vault");1279 await vault.createVault("correct horse battery staple");1280 await vault.restoreEntries(1281 [1282 {1283 id: "e1",1284 type: "login",1285 title: "Bank",1286 username: "june",1287 password: "pw",1288 createdAt: 1,1289 modifiedAt: 2,1290 evil: "payload",1291 isAdmin: true,1292 nested: { junk: 1 },1293 } as unknown as import("../shared/types").VaultEntry,1294 ],1295 "replace",1296 );1297 const restored = vault.getEntries().find((e) => e.id === "e1")!;1298 expect(restored.password).toBe("pw");1299 expect((restored as unknown as Record<string, unknown>).evil).toBeUndefined();1300 expect((restored as unknown as Record<string, unknown>).isAdmin).toBeUndefined();1301 expect((restored as unknown as Record<string, unknown>).nested).toBeUndefined();1302 });13031304 it("strips a non-HTTPS canary webhook but keeps the entry", async () => {1305 const vault = await import("./vault");1306 await vault.createVault("correct horse battery staple");1307 await vault.restoreEntries(1308 [1309 {1310 id: "e2",1311 type: "login",1312 title: "Bank",1313 password: "pw",1314 createdAt: 1,1315 modifiedAt: 2,1316 canary: { webhookUrl: "http://attacker.example.com/x" },1317 } as unknown as import("../shared/types").VaultEntry,1318 ],1319 "replace",1320 );1321 const restored = vault.getEntries().find((e) => e.id === "e2")!;1322 expect(restored).toBeDefined();1323 expect(restored.canary).toBeUndefined();1324 });13251326 it("preserves a well-formed HTTPS canary webhook", async () => {1327 const vault = await import("./vault");1328 await vault.createVault("correct horse battery staple");1329 await vault.restoreEntries(1330 [1331 {1332 id: "e3",1333 type: "login",1334 title: "Bank",1335 password: "pw",1336 createdAt: 1,1337 modifiedAt: 2,1338 canary: { webhookUrl: "https://hooks.example.com/x", label: "watch" },1339 } as unknown as import("../shared/types").VaultEntry,1340 ],1341 "replace",1342 );1343 const restored = vault.getEntries().find((e) => e.id === "e3")!;1344 expect(restored.canary?.webhookUrl).toBe("https://hooks.example.com/x");1345 expect(restored.canary?.label).toBe("watch");1346 });13471348 it("rejects an entry whose passkey shape is malformed (missing credentialId)", async () => {1349 const vault = await import("./vault");1350 await vault.createVault("correct horse battery staple");1351 await vault.restoreEntries(1352 [1353 {1354 id: "e4",1355 type: "login",1356 title: "Has bad passkey",1357 createdAt: 1,1358 modifiedAt: 2,1359 passkey: { rpId: "example.com", publicKeyCose: "x" },1360 } as unknown as import("../shared/types").VaultEntry,1361 ],1362 "replace",1363 );1364 expect(vault.getEntries().some((e) => e.id === "e4")).toBe(false);1365 });13661367 });13681369 describe("MV-8 rotateSyncIdentity", () => {1370 it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1371 const vault = await import("./vault");1372 await vault.createVault("correct horse battery staple");1373 const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1374 const { upsertPairedDevice, listPairedDevices } = await import(1375 "./paired-devices"1376 );1377 await upsertPairedDevice({1378 deviceId: "d1",1379 deviceName: "Phone",1380 deviceType: "android",1381 longTermPublicKeyHex: "a".repeat(64),1382 signingPublicKeyHex: "b".repeat(64),1383 pairedAt: 1,1384 trustSource: "direct",1385 });13861387 const rootBefore = vault.getRootKeyHex();1388 const res = await vault.rotateSyncIdentity();1389 expect(res.ok).toBe(true);1390 expect(vault.getRootKeyHex()).not.toBe(rootBefore);1391 expect(await listPairedDevices()).toHaveLength(0);1392 1393 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");13941395 vault.lock();1396 expect(await vault.unlock("correct horse battery staple")).toBe(true);1397 expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1398 });13991400 it("invalidates the old recovery words and flags recovery-pending", async () => {1401 const vault = await import("./vault");1402 await vault.createVault("correct horse battery staple");1403 const wordsBefore = (await vault.getRecoveryWords()).join(" ");14041405 await vault.rotateSyncIdentity();14061407 const wordsAfter = (await vault.getRecoveryWords()).join(" ");1408 expect(wordsAfter).not.toBe(wordsBefore);1409 const { isRecoveryConfirmPending } = await import("./recovery-pending");1410 expect(await isRecoveryConfirmPending()).toBe(true);1411 });14121413 it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1414 const vault = await import("./vault");1415 await vault.createVault("correct horse battery staple");1416 const { createSyncVaultAdapter } = await import(1417 "./sync-vault-adapter"1418 );1419 const adapter = createSyncVaultAdapter();1420 const vidBefore = await adapter.computeVaultId();14211422 await vault.rotateSyncIdentity();14231424 const vidAfter = await adapter.computeVaultId();1425 expect(vidAfter).not.toBe(vidBefore);1426 });1427 });14281429 describe("LV-1 NFC master password normalization", () => {1430 const CAFE_BASE = "café";1431 const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1432 const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;14331434 it("a vault created with the NFD form unlocks with the NFC form", async () => {1435 const vault = await import("./vault");1436 expect(PW_NFD).not.toBe(PW_NFC);1437 await vault.createVault(PW_NFD);1438 vault.lock();1439 expect(await vault.unlock(PW_NFC)).toBe(true);1440 });14411442 });14431444 describe("LV-7 updateSettings proto-pollution filter", () => {1445 it("strips __proto__/constructor/prototype keys while persisting real settings", async () => {1446 const vault = await import("./vault");1447 await vault.createVault("correct horse battery staple");14481449 await vault.updateSettings({1450 theme: "dark",1451 ["__proto__"]: { polluted: true },1452 constructor: { polluted: true },1453 } as never);14541455 expect(({} as Record<string, unknown>).polluted).toBeUndefined();1456 expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();1457 expect(vault.getSettings().theme).toBe("dark");1458 });1459 });14601461 1462 1463 1464 1465 describe("PB-26-011 url scheme allow-list", () => {1466 let vault: typeof import("./vault");14671468 beforeEach(async () => {1469 vault = await import("./vault");1470 await vault.createVault("correct horse battery staple");1471 });14721473 it("drops a javascript: url on addEntry", async () => {1474 const entry = await vault.addEntry({1475 title: "Evil",1476 username: "june",1477 password: "secret1",1478 url: "javascript:alert(1)",1479 });1480 const stored = vault.getEntryById(entry.id);1481 expect(stored?.url).toBe("");1482 });14831484 it("drops non-http(s) schemes from additionalUrls", async () => {1485 const entry = await vault.addEntry({1486 title: "Evil",1487 username: "june",1488 password: "secret1",1489 url: "https://ok.test",1490 additionalUrls: [1491 "javascript:alert(1)",1492 "data:text/html,<b>x</b>",1493 "https://also-ok.test",1494 ],1495 });1496 const stored = vault.getEntryById(entry.id);1497 expect(stored?.additionalUrls).toEqual(["https://also-ok.test"]);1498 });14991500 it("drops a data: url on UPDATE_ENTRY patch", async () => {1501 const entry = await vault.addEntry({1502 title: "Ok",1503 username: "june",1504 password: "secret1",1505 url: "https://ok.test",1506 });1507 await vault.updateEntry(entry.id, { url: "data:text/html,<b>x</b>" });1508 const stored = vault.getEntryById(entry.id);1509 expect(stored?.url).toBe("");1510 });15111512 it("keeps https and schemeless urls", async () => {1513 const entry = await vault.addEntry({1514 title: "Ok",1515 username: "june",1516 password: "secret1",1517 url: "https://github.com",1518 additionalUrls: ["github.com"],1519 });1520 const stored = vault.getEntryById(entry.id);1521 expect(stored?.url).toBe("https://github.com");1522 expect(stored?.additionalUrls).toEqual(["github.com"]);1523 });1524 });1525});