1234567891011121314import { beforeEach, describe, expect, it, vi } from "vitest";1516import type { Message, MessageResponse } from "../shared/types";1718type StorageShape = Record<string, unknown>;19type MessageListener = (20 msg: unknown,21 sender: chrome.runtime.MessageSender,22 sendResponse: (response: MessageResponse) => void23) => boolean;242526const PASSWORD_CASES = [27 { name: "single character", password: "a" },28 { name: "2 chars", password: "ab" },29 { name: "exactly 8 chars", password: "abcdefgh" },30 { name: "unicode password", password: "密码测试🔑" },31 { name: "emoji only", password: "🔑🔒🛡️" },32 { name: "very long (1000 chars)", password: "a".repeat(1000) },33 { name: "very long (10000 chars)", password: "a".repeat(10000) },34 { name: "all spaces (12)", password: " " },35 { name: "RTL text", password: "كلمة المرور" },36 { name: "mixed scripts", password: "パスワードPassword密码" },37 { name: "control characters", password: "\t\n\r\x1b\x7f" },38 { name: "SQL injection attempt", password: "' OR '1'='1" },39 { name: "JSON injection", password: '{"type":"UNLOCK"}' },40 { name: "leading whitespace", password: " pass-with-lead" },41 { name: "trailing whitespace", password: "pass-with-trail " },42 { name: "tab separated", password: "p1\tp2\tp3\tp4" },43 { name: "newline separated", password: "p1\np2\np3\np4" },44 { name: "high unicode", password: "\u{1F600}\u{1F601}\u{1F602}" },45 { name: "mixed case", password: "PassWordCorrect" },46 { name: "all digits", password: "1234567890" },47 { name: "punctuation heavy", password: "!@#$%^&*()_+-=[]{}|;':\",./<>?`~" },48 { name: "combining marks", password: "áb̂c̃" },49];5051function makeChromeMock(52 storage: StorageShape,53 sessionStorage: StorageShape,54 listenerSink: { listener?: MessageListener }55): typeof chrome {56 return {57 runtime: {58 id: "peach-extension",59 onMessage: {60 addListener: vi.fn((fn: MessageListener) => {61 listenerSink.listener = fn;62 }),63 },64 sendMessage: vi.fn(async () => ({ ok: true })),65 },66 tabs: {67 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {68 if (typeof cb === "function") {69 cb([{ id: 1 }]);70 return;71 }72 return Promise.resolve([{ id: 1 }]);73 }),74 sendMessage: vi.fn(async () => ({ ok: true })),75 },76 storage: {77 local: {78 get: vi.fn(async (key?: string | string[]) => {79 if (!key) return storage;80 if (typeof key === "string") return { [key]: storage[key] };81 return key.reduce<Record<string, unknown>>((acc, k) => {82 acc[k] = storage[k];83 return acc;84 }, {});85 }),86 set: vi.fn(async (next: Record<string, unknown>) => {87 Object.assign(storage, next);88 }),89 remove: vi.fn(async (keys: string | string[]) => {90 const list = Array.isArray(keys) ? keys : [keys];91 for (const key of list) delete storage[key];92 }),93 },94 session: {95 get: vi.fn(async (key?: string | string[]) => {96 if (!key) return sessionStorage;97 if (typeof key === "string") return { [key]: sessionStorage[key] };98 return key.reduce<Record<string, unknown>>((acc, k) => {99 acc[k] = sessionStorage[k];100 return acc;101 }, {});102 }),103 set: vi.fn(async (next: Record<string, unknown>) => {104 Object.assign(sessionStorage, next);105 }),106 remove: vi.fn(async (keys: string | string[]) => {107 const list = Array.isArray(keys) ? keys : [keys];108 for (const key of list) delete sessionStorage[key];109 }),110 clear: vi.fn(async () => {111 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];112 }),113 },114 },115 alarms: {116 clear: vi.fn(),117 create: vi.fn(),118 onAlarm: { addListener: vi.fn() },119 },120 idle: {121 setDetectionInterval: vi.fn(),122 onStateChanged: { addListener: vi.fn() },123 },124 scripting: { executeScript: vi.fn(async () => []) },125 } as unknown as typeof chrome;126}127128describe("vault adversarial inputs", () => {129 let storage: StorageShape;130 let sessionStorage: StorageShape;131 const sink: { listener?: MessageListener } = {};132133 beforeEach(async () => {134 vi.resetModules();135 vi.useRealTimers();136 storage = { peach_settings: { breachMonitoring: false } };137 sessionStorage = {};138 sink.listener = undefined;139140 vi.stubGlobal(141 "fetch",142 vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))143 );144 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);145146 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(147 storage,148 sessionStorage,149 sink150 );151152 const messages = await import("./messages");153 messages.setupMessageHandler();154 const vault = await import("./vault");155 await vault.ensureSettingsLoaded();156 });157158 async function dispatch(message: Message): Promise<MessageResponse> {159 if (!sink.listener) throw new Error("listener not registered");160 return new Promise<MessageResponse>((resolve) => {161 const keepOpen = sink.listener!(162 message,163 { id: "peach-extension" } as chrome.runtime.MessageSender,164 resolve165 );166 expect(keepOpen).toBe(true);167 });168 }169170 171172 describe("password boundary values — CREATE_VAULT + UNLOCK round-trips", () => {173 for (const { name, password } of PASSWORD_CASES) {174 it(`CREATE_VAULT with ${name} produces a vault unlockable with same password`, async () => {175 const vault = await import("./vault");176 await vault.createVault(password);177 vault.lock();178 const result = await dispatch({179 type: "UNLOCK",180 payload: { password },181 });182 expect(result.ok).toBe(true);183 });184185 it(`UNLOCK with wrong password after ${name} creation fails (and no crash)`, async () => {186 const vault = await import("./vault");187 await vault.createVault(password);188 vault.lock();189 const wrong = password + "x";190 const result = await dispatch({191 type: "UNLOCK",192 payload: { password: wrong },193 });194 expect(result.ok).toBe(false);195 });196197 it(`UNLOCK with empty after ${name} creation fails`, async () => {198 const vault = await import("./vault");199 await vault.createVault(password);200 vault.lock();201 const result = await dispatch({202 type: "UNLOCK",203 payload: { password: "" },204 });205 206 expect(result.ok).toBe(password === "");207 });208209 it(`re-locking after ${name} unlock cleans up state (IS_UNLOCKED=false)`, async () => {210 const vault = await import("./vault");211 await vault.createVault(password);212 await dispatch({ type: "LOCK" });213 const r = await dispatch({ type: "IS_UNLOCKED" });214 expect(r.ok).toBe(true);215 expect((r as { data: boolean }).data).toBe(false);216 });217 }218 });219220 221222 describe("entry boundary values", () => {223 async function setupUnlocked() {224 const vault = await import("./vault");225 await vault.createVault("test-pass-12345");226 }227228 it("entry with empty title is accepted", async () => {229 await setupUnlocked();230 const r = await dispatch({231 type: "ADD_ENTRY",232 payload: { title: "", username: "u", password: "test", url: "https://e.com" },233 });234 expect(r.ok).toBe(true);235 });236237 const titleCases = [238 { name: "1 char title", title: "x" },239 { name: "100 char title", title: "x".repeat(100) },240 { name: "1000 char title", title: "x".repeat(1000) },241 { name: "10000 char title", title: "x".repeat(10000) },242 { name: "unicode title", title: "日本語 🔑 Ünïcödé" },243 { name: "emoji title", title: "🔑🔒🛡️🌸" },244 { name: "RTL title", title: "كلمة المرور" },245 { name: "tab in title", title: "Tab\there" },246 { name: "newline in title", title: "Line1\nLine2" },247 { name: "html-looking title", title: "<script>alert(1)</script>" },248 { name: "sql-looking title", title: "' OR '1'='1" },249 { name: "null character in title", title: "before\x00after" },250 { name: "control chars title", title: "\x01\x02\x03\x04" },251 ];252253 for (const { name, title } of titleCases) {254 it(`entry with ${name} is handled without crash`, async () => {255 await setupUnlocked();256 const r = await dispatch({257 type: "ADD_ENTRY",258 payload: { title, username: "u", password: "p", url: "https://e.com" },259 });260 expect(r).toBeDefined();261 });262263 it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {264 await setupUnlocked();265 await dispatch({266 type: "ADD_ENTRY",267 payload: { title, username: "u", password: "p", url: "https://e.com" },268 });269 const list = await dispatch({ type: "GET_ENTRIES" });270 expect(list.ok).toBe(true);271 const entries = (list as { data: Array<{ title: string }> }).data;272 expect(entries.length).toBe(1);273 274 expect(entries[0].title).toBe(title);275 });276 }277278 const urlCases = [279 { name: "https URL", url: "https://github.com" },280 { name: "http URL", url: "http://example.com" },281 { name: "javascript URL", url: "javascript:alert('xss')" },282 { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },283 { name: "file URL", url: "file:///etc/passwd" },284 { name: "empty URL", url: "" },285 { name: "spaces URL", url: " " },286 { name: "ftp URL", url: "ftp://example.com" },287 { name: "IPv6 URL", url: "https://[::1]" },288 { name: "IDN URL", url: "https://日本.jp" },289 { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },290 ];291292 for (const { name, url } of urlCases) {293 it(`entry with ${name} stored as-is`, async () => {294 await setupUnlocked();295 const r = await dispatch({296 type: "ADD_ENTRY",297 payload: { title: "t", username: "u", password: "p", url },298 });299 expect(r.ok).toBe(true);300 });301 }302303 it("100 entries: count consistent", async () => {304 await setupUnlocked();305 for (let i = 0; i < 100; i++) {306 await dispatch({307 type: "ADD_ENTRY",308 payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },309 });310 }311 const list = await dispatch({ type: "GET_ENTRIES" });312 expect((list as { data: unknown[] }).data.length).toBe(100);313 });314315 it("vault remains unlockable after many entries", async () => {316 await setupUnlocked();317 const vault = await import("./vault");318 for (let i = 0; i < 50; i++) {319 await vault.addEntry({320 title: `E${i}`,321 username: "u",322 password: `p${i}`,323 url: "https://e.com",324 type: "login",325 });326 }327 vault.lock();328 const unlock = await dispatch({329 type: "UNLOCK",330 payload: { password: "test-pass-12345" },331 });332 expect(unlock.ok).toBe(true);333 const list = await dispatch({ type: "GET_ENTRIES" });334 expect((list as { data: unknown[] }).data.length).toBe(50);335 });336337 it("add then delete leaves no live entries", async () => {338 await setupUnlocked();339 const r = await dispatch({340 type: "ADD_ENTRY",341 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },342 });343 const id = (r as { data: { id: string } }).data.id;344 await dispatch({ type: "DELETE_ENTRY", payload: { id } });345 const list = await dispatch({ type: "GET_ENTRIES" });346 expect((list as { data: unknown[] }).data.length).toBe(0);347 });348 });349350 351352 describe("concurrent operations", () => {353 async function setupLocked() {354 const vault = await import("./vault");355 await vault.createVault("test-pass-12345");356 vault.lock();357 }358359 it("simultaneous UNLOCK calls leave vault unlocked", async () => {360 await setupLocked();361 const [r1, r2] = await Promise.all([362 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),363 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),364 ]);365 expect(r1.ok || r2.ok).toBe(true);366 const status = await dispatch({ type: "IS_UNLOCKED" });367 expect((status as { data: boolean }).data).toBe(true);368 });369370 it("simultaneous wrong UNLOCKs leave vault locked", async () => {371 await setupLocked();372 await Promise.all([373 dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),374 dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),375 ]);376 const status = await dispatch({ type: "IS_UNLOCKED" });377 expect((status as { data: boolean }).data).toBe(false);378 });379380 it("ADD_ENTRY during LOCK does not corrupt vault", async () => {381 const vault = await import("./vault");382 await vault.createVault("test-pass-12345");383 await Promise.all([384 dispatch({385 type: "ADD_ENTRY",386 payload: { title: "t", username: "u", password: "p", url: "https://e.com" },387 }).catch(() => null),388 dispatch({ type: "LOCK" }),389 ]);390 const unlock = await dispatch({391 type: "UNLOCK",392 payload: { password: "test-pass-12345" },393 });394 expect(unlock.ok).toBe(true);395 });396397 for (const count of [5, 10, 20, 50]) {398 it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {399 const vault = await import("./vault");400 await vault.createVault("test-pass-12345");401 await Promise.all(402 Array.from({ length: count }, (_, i) =>403 dispatch({404 type: "ADD_ENTRY",405 payload: {406 title: `E${i}`,407 username: "u",408 password: "p",409 url: "https://e.com",410 },411 })412 )413 );414 const list = await dispatch({ type: "GET_ENTRIES" });415 expect((list as { data: unknown[] }).data.length).toBe(count);416 });417 }418419 it("interleaved ADD + UPDATE keeps entry counts stable", async () => {420 const vault = await import("./vault");421 await vault.createVault("test-pass-12345");422 const r = await dispatch({423 type: "ADD_ENTRY",424 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },425 });426 const id = (r as { data: { id: string } }).data.id;427 await Promise.all([428 dispatch({429 type: "ADD_ENTRY",430 payload: { title: "y", username: "u", password: "p", url: "https://e.com" },431 }),432 dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),433 dispatch({434 type: "ADD_ENTRY",435 payload: { title: "z", username: "u", password: "p", url: "https://e.com" },436 }),437 ]);438 const list = await dispatch({ type: "GET_ENTRIES" });439 expect((list as { data: unknown[] }).data.length).toBe(3);440 });441 });442443 444445 describe("storage corruption", () => {446 async function setupVaultInStorage() {447 const vault = await import("./vault");448 await vault.createVault("test-pass-12345");449 vault.lock();450 }451452 it("non-base64 slot A: unlock fails gracefully", async () => {453 await setupVaultInStorage();454 storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;455 const r = await dispatch({456 type: "UNLOCK",457 payload: { password: "test-pass-12345" },458 });459 expect(r.ok).toBe(false);460 });461462 it("missing slot A: unlock fails", async () => {463 await setupVaultInStorage();464 delete storage.peach_vault_a;465 const r = await dispatch({466 type: "UNLOCK",467 payload: { password: "test-pass-12345" },468 });469 expect(r.ok).toBe(false);470 });471472 it("missing slot B: unlock still works from slot A", async () => {473 await setupVaultInStorage();474 delete storage.peach_vault_b;475 const r = await dispatch({476 type: "UNLOCK",477 payload: { password: "test-pass-12345" },478 });479 expect(r.ok).toBe(true);480 });481482 it("corrupt ciphertext in slot A: unlock fails", async () => {483 await setupVaultInStorage();484 const a = storage.peach_vault_a as { ciphertext: string };485 storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };486 const r = await dispatch({487 type: "UNLOCK",488 payload: { password: "test-pass-12345" },489 });490 expect(r.ok).toBe(false);491 });492493 it("corrupt salt: unlock fails", async () => {494 await setupVaultInStorage();495 const a = storage.peach_vault_a as { salt: string };496 storage.peach_vault_a = { ...a, salt: "00".repeat(16) };497 const r = await dispatch({498 type: "UNLOCK",499 payload: { password: "test-pass-12345" },500 });501 expect(r.ok).toBe(false);502 });503504 it("corrupt nonce: unlock fails", async () => {505 await setupVaultInStorage();506 const a = storage.peach_vault_a as { nonce: string };507 storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };508 const r = await dispatch({509 type: "UNLOCK",510 payload: { password: "test-pass-12345" },511 });512 expect(r.ok).toBe(false);513 });514515 it("corrupt tag: unlock fails", async () => {516 await setupVaultInStorage();517 const a = storage.peach_vault_a as { tag: string };518 storage.peach_vault_a = { ...a, tag: "00".repeat(16) };519 const r = await dispatch({520 type: "UNLOCK",521 payload: { password: "test-pass-12345" },522 });523 expect(r.ok).toBe(false);524 });525526 it("truncated ciphertext: unlock fails", async () => {527 await setupVaultInStorage();528 const a = storage.peach_vault_a as { ciphertext: string };529 storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };530 const r = await dispatch({531 type: "UNLOCK",532 payload: { password: "test-pass-12345" },533 });534 expect(r.ok).toBe(false);535 });536537 it("empty ciphertext: unlock fails", async () => {538 await setupVaultInStorage();539 const a = storage.peach_vault_a as { ciphertext: string };540 storage.peach_vault_a = { ...a, ciphertext: "" };541 const r = await dispatch({542 type: "UNLOCK",543 payload: { password: "test-pass-12345" },544 });545 expect(r.ok).toBe(false);546 });547548 for (const flipPos of [0, 4, 8, 16, 32, 64]) {549 it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {550 await setupVaultInStorage();551 const a = storage.peach_vault_a as { ciphertext: string };552 if (a.ciphertext.length <= flipPos + 1) return;553 const flipped =554 a.ciphertext.slice(0, flipPos) +555 (a.ciphertext[flipPos] === "0" ? "f" : "0") +556 a.ciphertext.slice(flipPos + 1);557 storage.peach_vault_a = { ...a, ciphertext: flipped };558 const r = await dispatch({559 type: "UNLOCK",560 payload: { password: "test-pass-12345" },561 });562 expect(r.ok).toBe(false);563 });564 }565566 it("after corruption, original storage record still exists (no wipe)", async () => {567 await setupVaultInStorage();568 const a = storage.peach_vault_a as { ciphertext: string };569 storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };570 await dispatch({571 type: "UNLOCK",572 payload: { password: "test-pass-12345" },573 });574 expect(storage.peach_vault_a).toBeDefined();575 });576 });577578 579580 describe("repeated wrong unlock attempts", () => {581 async function setupLocked() {582 const vault = await import("./vault");583 await vault.createVault("test-pass-12345");584 vault.lock();585 }586587 for (let attempt = 1; attempt <= 10; attempt++) {588 it(`attempt ${attempt}: wrong password returns ok=false`, async () => {589 await setupLocked();590 for (let i = 0; i < attempt - 1; i++) {591 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });592 }593 const r = await dispatch({594 type: "UNLOCK",595 payload: { password: "wrong" },596 });597 expect(r.ok).toBe(false);598 });599 }600601 it(602 "vault data is preserved across 25 wrong attempts",603 async () => {604 await setupLocked();605 for (let i = 0; i < 25; i++) {606 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });607 }608 expect(storage.peach_vault_a).toBeTruthy();609 610 const ok = await dispatch({611 type: "UNLOCK",612 payload: { password: "test-pass-12345" },613 });614 expect(ok.ok).toBe(true);615 },616 60_000,617 );618619 it(620 "vault data is preserved across 50 wrong attempts",621 async () => {622 await setupLocked();623 for (let i = 0; i < 50; i++) {624 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });625 }626 expect(storage.peach_vault_a).toBeTruthy();627 },628 120_000,629 );630631 it("successful unlock after wrong attempts produces unlocked state", async () => {632 await setupLocked();633 for (let i = 0; i < 5; i++) {634 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });635 }636 const r = await dispatch({637 type: "UNLOCK",638 payload: { password: "test-pass-12345" },639 });640 expect(r.ok).toBe(true);641 const status = await dispatch({ type: "IS_UNLOCKED" });642 expect((status as { data: boolean }).data).toBe(true);643 });644645 it("incorrect-then-correct: state is unlocked", async () => {646 await setupLocked();647 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });648 const r = await dispatch({649 type: "UNLOCK",650 payload: { password: "test-pass-12345" },651 });652 expect(r.ok).toBe(true);653 });654 });655656 657658 describe("generated random password fuzz round-trips", () => {659 660 const seeded: string[] = [];661 let rng = 0xdeadbeef;662 function next() {663 rng = (rng * 1103515245 + 12345) & 0x7fffffff;664 return rng;665 }666 for (let i = 0; i < 50; i++) {667 const len = (next() % 30) + 4;668 let s = "";669 for (let j = 0; j < len; j++) {670 s += String.fromCharCode((next() % 94) + 33);671 }672 seeded.push(s);673 }674 for (const [i, password] of seeded.entries()) {675 it(`fuzz password #${i} round-trips`, async () => {676 const vault = await import("./vault");677 await vault.createVault(password);678 vault.lock();679 const r = await dispatch({ type: "UNLOCK", payload: { password } });680 expect(r.ok).toBe(true);681 });682683 it(`fuzz password #${i} wrong rejects`, async () => {684 const vault = await import("./vault");685 await vault.createVault(password);686 vault.lock();687 const r = await dispatch({688 type: "UNLOCK",689 payload: { password: password.slice(0, -1) + "Z" },690 });691 expect(r.ok).toBe(false);692 });693 }694 });695696 697698 describe("entry lifecycle parameterized", () => {699 for (const n of [1, 2, 3, 5, 10, 25, 50]) {700 it(`add ${n} entries then delete all leaves no live entries`, async () => {701 const vault = await import("./vault");702 await vault.createVault("test-pass-12345");703 const ids: string[] = [];704 for (let i = 0; i < n; i++) {705 const r = await dispatch({706 type: "ADD_ENTRY",707 payload: {708 title: `E${i}`,709 username: "u",710 password: "p",711 url: "https://e.com",712 },713 });714 ids.push((r as { data: { id: string } }).data.id);715 }716 for (const id of ids) {717 await dispatch({ type: "DELETE_ENTRY", payload: { id } });718 }719 const list = await dispatch({ type: "GET_ENTRIES" });720 expect((list as { data: unknown[] }).data.length).toBe(0);721 });722723 it(`add ${n} entries then update each title`, async () => {724 const vault = await import("./vault");725 await vault.createVault("test-pass-12345");726 const ids: string[] = [];727 for (let i = 0; i < n; i++) {728 const r = await dispatch({729 type: "ADD_ENTRY",730 payload: {731 title: `E${i}`,732 username: "u",733 password: "p",734 url: "https://e.com",735 },736 });737 ids.push((r as { data: { id: string } }).data.id);738 }739 for (const [i, id] of ids.entries()) {740 await dispatch({741 type: "UPDATE_ENTRY",742 payload: { id, patch: { title: `Updated${i}` } },743 });744 }745 const list = await dispatch({ type: "GET_ENTRIES" });746 const titles = (747 list as { data: Array<{ title: string }> }748 ).data.map((e) => e.title);749 for (let i = 0; i < n; i++) {750 expect(titles).toContain(`Updated${i}`);751 }752 });753 }754 });755756 757758 describe("lock/unlock churn", () => {759 for (const cycles of [1, 2, 3, 5, 10]) {760 it(`${cycles} lock/unlock cycles preserve entries`, async () => {761 const vault = await import("./vault");762 await vault.createVault("test-pass-12345");763 await vault.addEntry({764 title: "Persistent",765 username: "u",766 password: "p",767 url: "https://e.com",768 type: "login",769 });770 for (let i = 0; i < cycles; i++) {771 vault.lock();772 const r = await dispatch({773 type: "UNLOCK",774 payload: { password: "test-pass-12345" },775 });776 expect(r.ok).toBe(true);777 }778 const list = await dispatch({ type: "GET_ENTRIES" });779 expect((list as { data: unknown[] }).data.length).toBe(1);780 });781 }782 });783784 785786 describe("per-field corruption matrix", () => {787 async function setup() {788 const vault = await import("./vault");789 await vault.createVault("test-pass-12345");790 vault.lock();791 }792793 const fields = [794 "salt",795 "nonce",796 "ciphertext",797 "tag",798 "version",799 ];800 const corruptions = [801 { name: "empty", value: "" },802 { name: "garbage hex", value: "zzzz" },803 { name: "wrong length 1", value: "ab" },804 { name: "wrong length long", value: "ab".repeat(200) },805 { name: "non-string number", value: 42 as unknown as string },806 { name: "non-string null", value: null as unknown as string },807 ];808 for (const field of fields) {809 for (const { name, value } of corruptions) {810 it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {811 await setup();812 const a = storage.peach_vault_a as Record<string, unknown>;813 storage.peach_vault_a = { ...a, [field]: value };814 const r = await dispatch({815 type: "UNLOCK",816 payload: { password: "test-pass-12345" },817 });818 expect(r).toBeDefined();819 820 if (821 field !== "version" ||822 (typeof value !== "string" && value !== null)823 ) {824 expect(r.ok).toBe(false);825 }826 });827 }828 }829 });830831 832833 describe("vault corruption surfaces vault_corrupted error", () => {834 async function setup() {835 const vault = await import("./vault");836 await vault.createVault("test-pass-12345");837 vault.lock();838 }839840 it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {841 await setup();842 const a = storage.peach_vault_a as Record<string, unknown>;843 storage.peach_vault_a = { ...a, version: 42 };844 const r = await dispatch({845 type: "UNLOCK",846 payload: { password: "test-pass-12345" },847 });848 expect(r.ok).toBe(false);849 expect((r as { error?: string }).error).toBe("vault_corrupted");850 expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);851 });852853 it("wrong password (correct schema) does NOT return vault_corrupted", async () => {854 await setup();855 const r = await dispatch({856 type: "UNLOCK",857 payload: { password: "wrong-password" },858 });859 expect(r.ok).toBe(false);860 expect((r as { error?: string }).error).not.toBe("vault_corrupted");861 });862 });863864 865866 describe("add-then-relock churn", () => {867 for (let seed = 0; seed < 20; seed++) {868 it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {869 const vault = await import("./vault");870 await vault.createVault("test-pass-12345");871 for (let i = 0; i < 3; i++) {872 await vault.addEntry({873 title: `seed-${seed}-${i}`,874 username: `u${i}`,875 password: `p${i}`,876 url: `https://e${seed}.com`,877 type: "login",878 });879 }880 vault.lock();881 const r = await dispatch({882 type: "UNLOCK",883 payload: { password: "test-pass-12345" },884 });885 expect(r.ok).toBe(true);886 const list = await dispatch({ type: "GET_ENTRIES" });887 expect((list as { data: unknown[] }).data.length).toBe(3);888 });889 }890 });891892 893894 describe("UNLOCK no-crash matrix", () => {895 const adversarialPayloads = [896 { name: "null password", payload: { password: null as unknown as string } },897 { name: "undefined password", payload: { password: undefined as unknown as string } },898 { name: "number password", payload: { password: 12345 as unknown as string } },899 { name: "bool password", payload: { password: true as unknown as string } },900 { name: "array password", payload: { password: ["a", "b"] as unknown as string } },901 { name: "object password", payload: { password: { x: 1 } as unknown as string } },902 { name: "missing password field", payload: {} as { password: string } },903 { name: "empty password", payload: { password: "" } },904 ];905906 for (const { name, payload } of adversarialPayloads) {907 it(`UNLOCK with ${name} resolves without crash`, async () => {908 const vault = await import("./vault");909 await vault.createVault("test-pass-12345");910 vault.lock();911 const r = await dispatch({ type: "UNLOCK", payload });912 expect(r).toBeDefined();913 });914 }915 });916});