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 rejected by sanitizeEntryDraft", async () => {229 230 231 await setupUnlocked();232 const r = await dispatch({233 type: "ADD_ENTRY",234 payload: { title: "", username: "u", password: "test", url: "https://e.com" },235 });236 expect(r.ok).toBe(false);237 });238239 const titleCases = [240 { name: "1 char title", title: "x" },241 { name: "100 char title", title: "x".repeat(100) },242 { name: "1000 char title", title: "x".repeat(1000) },243 { name: "10000 char title", title: "x".repeat(10000) },244 { name: "unicode title", title: "日本語 🔑 Ünïcödé" },245 { name: "emoji title", title: "🔑🔒🛡️🌸" },246 { name: "RTL title", title: "كلمة المرور" },247 { name: "tab in title", title: "Tab\there" },248 { name: "newline in title", title: "Line1\nLine2" },249 { name: "html-looking title", title: "<script>alert(1)</script>" },250 { name: "sql-looking title", title: "' OR '1'='1" },251 { name: "null character in title", title: "before\x00after" },252 { name: "control chars title", title: "\x01\x02\x03\x04" },253 ];254255 for (const { name, title } of titleCases) {256 it(`entry with ${name} is handled without crash`, async () => {257 await setupUnlocked();258 const r = await dispatch({259 type: "ADD_ENTRY",260 payload: { title, username: "u", password: "p", url: "https://e.com" },261 });262 expect(r).toBeDefined();263 });264265 it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {266 await setupUnlocked();267 await dispatch({268 type: "ADD_ENTRY",269 payload: { title, username: "u", password: "p", url: "https://e.com" },270 });271 const list = await dispatch({ type: "GET_ENTRIES" });272 expect(list.ok).toBe(true);273 const entries = (list as { data: Array<{ title: string }> }).data;274 expect(entries.length).toBe(1);275 276 expect(entries[0].title).toBe(title);277 });278 }279280 const urlCases = [281 { name: "https URL", url: "https://github.com" },282 { name: "http URL", url: "http://example.com" },283 { name: "javascript URL", url: "javascript:alert('xss')" },284 { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },285 { name: "file URL", url: "file:///etc/passwd" },286 { name: "empty URL", url: "" },287 { name: "spaces URL", url: " " },288 { name: "ftp URL", url: "ftp://example.com" },289 { name: "IPv6 URL", url: "https://[::1]" },290 { name: "IDN URL", url: "https://日本.jp" },291 { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },292 ];293294 for (const { name, url } of urlCases) {295 it(`entry with ${name} stored as-is`, async () => {296 await setupUnlocked();297 const r = await dispatch({298 type: "ADD_ENTRY",299 payload: { title: "t", username: "u", password: "p", url },300 });301 expect(r.ok).toBe(true);302 });303 }304305 it("100 entries: count consistent", async () => {306 await setupUnlocked();307 for (let i = 0; i < 100; i++) {308 await dispatch({309 type: "ADD_ENTRY",310 payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },311 });312 }313 const list = await dispatch({ type: "GET_ENTRIES" });314 expect((list as { data: unknown[] }).data.length).toBe(100);315 });316317 it("vault remains unlockable after many entries", async () => {318 await setupUnlocked();319 const vault = await import("./vault");320 for (let i = 0; i < 50; i++) {321 await vault.addEntry({322 title: `E${i}`,323 username: "u",324 password: `p${i}`,325 url: "https://e.com",326 type: "login",327 });328 }329 vault.lock();330 const unlock = await dispatch({331 type: "UNLOCK",332 payload: { password: "test-pass-12345" },333 });334 expect(unlock.ok).toBe(true);335 const list = await dispatch({ type: "GET_ENTRIES" });336 expect((list as { data: unknown[] }).data.length).toBe(50);337 });338339 it("add then delete leaves no live entries", async () => {340 await setupUnlocked();341 const r = await dispatch({342 type: "ADD_ENTRY",343 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },344 });345 const id = (r as { data: { id: string } }).data.id;346 await dispatch({ type: "DELETE_ENTRY", payload: { id } });347 const list = await dispatch({ type: "GET_ENTRIES" });348 expect((list as { data: unknown[] }).data.length).toBe(0);349 });350 });351352 353354 describe("concurrent operations", () => {355 async function setupLocked() {356 const vault = await import("./vault");357 await vault.createVault("test-pass-12345");358 vault.lock();359 }360361 it("simultaneous UNLOCK calls leave vault unlocked", async () => {362 await setupLocked();363 const [r1, r2] = await Promise.all([364 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),365 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),366 ]);367 expect(r1.ok || r2.ok).toBe(true);368 const status = await dispatch({ type: "IS_UNLOCKED" });369 expect((status as { data: boolean }).data).toBe(true);370 });371372 it("simultaneous wrong UNLOCKs leave vault locked", async () => {373 await setupLocked();374 await Promise.all([375 dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),376 dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),377 ]);378 const status = await dispatch({ type: "IS_UNLOCKED" });379 expect((status as { data: boolean }).data).toBe(false);380 });381382 it("ADD_ENTRY during LOCK does not corrupt vault", async () => {383 const vault = await import("./vault");384 await vault.createVault("test-pass-12345");385 await Promise.all([386 dispatch({387 type: "ADD_ENTRY",388 payload: { title: "t", username: "u", password: "p", url: "https://e.com" },389 }).catch(() => null),390 dispatch({ type: "LOCK" }),391 ]);392 const unlock = await dispatch({393 type: "UNLOCK",394 payload: { password: "test-pass-12345" },395 });396 expect(unlock.ok).toBe(true);397 });398399 for (const count of [5, 10, 20, 50]) {400 it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {401 const vault = await import("./vault");402 await vault.createVault("test-pass-12345");403 await Promise.all(404 Array.from({ length: count }, (_, i) =>405 dispatch({406 type: "ADD_ENTRY",407 payload: {408 title: `E${i}`,409 username: "u",410 password: "p",411 url: "https://e.com",412 },413 })414 )415 );416 const list = await dispatch({ type: "GET_ENTRIES" });417 expect((list as { data: unknown[] }).data.length).toBe(count);418 });419 }420421 it("interleaved ADD + UPDATE keeps entry counts stable", async () => {422 const vault = await import("./vault");423 await vault.createVault("test-pass-12345");424 const r = await dispatch({425 type: "ADD_ENTRY",426 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },427 });428 const id = (r as { data: { id: string } }).data.id;429 await Promise.all([430 dispatch({431 type: "ADD_ENTRY",432 payload: { title: "y", username: "u", password: "p", url: "https://e.com" },433 }),434 dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),435 dispatch({436 type: "ADD_ENTRY",437 payload: { title: "z", username: "u", password: "p", url: "https://e.com" },438 }),439 ]);440 const list = await dispatch({ type: "GET_ENTRIES" });441 expect((list as { data: unknown[] }).data.length).toBe(3);442 });443 });444445 446447 describe("storage corruption", () => {448 async function setupVaultInStorage() {449 const vault = await import("./vault");450 await vault.createVault("test-pass-12345");451 vault.lock();452 }453454 it("non-base64 slot A: unlock fails gracefully", async () => {455 await setupVaultInStorage();456 storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;457 const r = await dispatch({458 type: "UNLOCK",459 payload: { password: "test-pass-12345" },460 });461 expect(r.ok).toBe(false);462 });463464 it("missing slot A: unlock fails", async () => {465 await setupVaultInStorage();466 delete storage.peach_vault_a;467 const r = await dispatch({468 type: "UNLOCK",469 payload: { password: "test-pass-12345" },470 });471 expect(r.ok).toBe(false);472 });473474 it("missing slot B: unlock still works from slot A", async () => {475 await setupVaultInStorage();476 delete storage.peach_vault_b;477 const r = await dispatch({478 type: "UNLOCK",479 payload: { password: "test-pass-12345" },480 });481 expect(r.ok).toBe(true);482 });483484 it("corrupt ciphertext in slot A: unlock fails", async () => {485 await setupVaultInStorage();486 const a = storage.peach_vault_a as { ciphertext: string };487 storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };488 const r = await dispatch({489 type: "UNLOCK",490 payload: { password: "test-pass-12345" },491 });492 expect(r.ok).toBe(false);493 });494495 it("corrupt salt: unlock fails", async () => {496 await setupVaultInStorage();497 const a = storage.peach_vault_a as { salt: string };498 storage.peach_vault_a = { ...a, salt: "00".repeat(16) };499 const r = await dispatch({500 type: "UNLOCK",501 payload: { password: "test-pass-12345" },502 });503 expect(r.ok).toBe(false);504 });505506 it("corrupt nonce: unlock fails", async () => {507 await setupVaultInStorage();508 const a = storage.peach_vault_a as { nonce: string };509 storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };510 const r = await dispatch({511 type: "UNLOCK",512 payload: { password: "test-pass-12345" },513 });514 expect(r.ok).toBe(false);515 });516517 it("corrupt tag: unlock fails", async () => {518 await setupVaultInStorage();519 const a = storage.peach_vault_a as { tag: string };520 storage.peach_vault_a = { ...a, tag: "00".repeat(16) };521 const r = await dispatch({522 type: "UNLOCK",523 payload: { password: "test-pass-12345" },524 });525 expect(r.ok).toBe(false);526 });527528 it("truncated ciphertext: unlock fails", async () => {529 await setupVaultInStorage();530 const a = storage.peach_vault_a as { ciphertext: string };531 storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };532 const r = await dispatch({533 type: "UNLOCK",534 payload: { password: "test-pass-12345" },535 });536 expect(r.ok).toBe(false);537 });538539 it("empty ciphertext: unlock fails", async () => {540 await setupVaultInStorage();541 const a = storage.peach_vault_a as { ciphertext: string };542 storage.peach_vault_a = { ...a, ciphertext: "" };543 const r = await dispatch({544 type: "UNLOCK",545 payload: { password: "test-pass-12345" },546 });547 expect(r.ok).toBe(false);548 });549550 for (const flipPos of [0, 4, 8, 16, 32, 64]) {551 it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {552 await setupVaultInStorage();553 const a = storage.peach_vault_a as { ciphertext: string };554 if (a.ciphertext.length <= flipPos + 1) return;555 const flipped =556 a.ciphertext.slice(0, flipPos) +557 (a.ciphertext[flipPos] === "0" ? "f" : "0") +558 a.ciphertext.slice(flipPos + 1);559 storage.peach_vault_a = { ...a, ciphertext: flipped };560 const r = await dispatch({561 type: "UNLOCK",562 payload: { password: "test-pass-12345" },563 });564 expect(r.ok).toBe(false);565 });566 }567568 it("after corruption, original storage record still exists (no wipe)", async () => {569 await setupVaultInStorage();570 const a = storage.peach_vault_a as { ciphertext: string };571 storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };572 await dispatch({573 type: "UNLOCK",574 payload: { password: "test-pass-12345" },575 });576 expect(storage.peach_vault_a).toBeDefined();577 });578 });579580 581582 describe("repeated wrong unlock attempts", () => {583 async function setupLocked() {584 const vault = await import("./vault");585 await vault.createVault("test-pass-12345");586 vault.lock();587 }588589 for (let attempt = 1; attempt <= 10; attempt++) {590 it(`attempt ${attempt}: wrong password returns ok=false`, async () => {591 await setupLocked();592 for (let i = 0; i < attempt - 1; i++) {593 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });594 }595 const r = await dispatch({596 type: "UNLOCK",597 payload: { password: "wrong" },598 });599 expect(r.ok).toBe(false);600 });601 }602603 it(604 "vault data is preserved across 25 wrong attempts",605 async () => {606 await setupLocked();607 for (let i = 0; i < 25; i++) {608 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });609 }610 expect(storage.peach_vault_a).toBeTruthy();611 612 const ok = await dispatch({613 type: "UNLOCK",614 payload: { password: "test-pass-12345" },615 });616 expect(ok.ok).toBe(true);617 },618 60_000,619 );620621 it(622 "vault data is preserved across 50 wrong attempts",623 async () => {624 await setupLocked();625 for (let i = 0; i < 50; i++) {626 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });627 }628 expect(storage.peach_vault_a).toBeTruthy();629 },630 120_000,631 );632633 it("successful unlock after wrong attempts produces unlocked state", async () => {634 await setupLocked();635 for (let i = 0; i < 5; i++) {636 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });637 }638 const r = await dispatch({639 type: "UNLOCK",640 payload: { password: "test-pass-12345" },641 });642 expect(r.ok).toBe(true);643 const status = await dispatch({ type: "IS_UNLOCKED" });644 expect((status as { data: boolean }).data).toBe(true);645 });646647 it("incorrect-then-correct: state is unlocked", async () => {648 await setupLocked();649 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });650 const r = await dispatch({651 type: "UNLOCK",652 payload: { password: "test-pass-12345" },653 });654 expect(r.ok).toBe(true);655 });656 });657658 659660 describe("generated random password fuzz round-trips", () => {661 662 const seeded: string[] = [];663 let rng = 0xdeadbeef;664 function next() {665 rng = (rng * 1103515245 + 12345) & 0x7fffffff;666 return rng;667 }668 for (let i = 0; i < 50; i++) {669 const len = (next() % 30) + 4;670 let s = "";671 for (let j = 0; j < len; j++) {672 s += String.fromCharCode((next() % 94) + 33);673 }674 seeded.push(s);675 }676 for (const [i, password] of seeded.entries()) {677 it(`fuzz password #${i} round-trips`, async () => {678 const vault = await import("./vault");679 await vault.createVault(password);680 vault.lock();681 const r = await dispatch({ type: "UNLOCK", payload: { password } });682 expect(r.ok).toBe(true);683 });684685 it(`fuzz password #${i} wrong rejects`, async () => {686 const vault = await import("./vault");687 await vault.createVault(password);688 vault.lock();689 const r = await dispatch({690 type: "UNLOCK",691 payload: { password: password.slice(0, -1) + "Z" },692 });693 expect(r.ok).toBe(false);694 });695 }696 });697698 699700 describe("entry lifecycle parameterized", () => {701 for (const n of [1, 2, 3, 5, 10, 25, 50]) {702 it(`add ${n} entries then delete all leaves no live entries`, async () => {703 const vault = await import("./vault");704 await vault.createVault("test-pass-12345");705 const ids: string[] = [];706 for (let i = 0; i < n; i++) {707 const r = await dispatch({708 type: "ADD_ENTRY",709 payload: {710 title: `E${i}`,711 username: "u",712 password: "p",713 url: "https://e.com",714 },715 });716 ids.push((r as { data: { id: string } }).data.id);717 }718 for (const id of ids) {719 await dispatch({ type: "DELETE_ENTRY", payload: { id } });720 }721 const list = await dispatch({ type: "GET_ENTRIES" });722 expect((list as { data: unknown[] }).data.length).toBe(0);723 });724725 it(`add ${n} entries then update each title`, async () => {726 const vault = await import("./vault");727 await vault.createVault("test-pass-12345");728 const ids: string[] = [];729 for (let i = 0; i < n; i++) {730 const r = await dispatch({731 type: "ADD_ENTRY",732 payload: {733 title: `E${i}`,734 username: "u",735 password: "p",736 url: "https://e.com",737 },738 });739 ids.push((r as { data: { id: string } }).data.id);740 }741 for (const [i, id] of ids.entries()) {742 await dispatch({743 type: "UPDATE_ENTRY",744 payload: { id, patch: { title: `Updated${i}` } },745 });746 }747 const list = await dispatch({ type: "GET_ENTRIES" });748 const titles = (749 list as { data: Array<{ title: string }> }750 ).data.map((e) => e.title);751 for (let i = 0; i < n; i++) {752 expect(titles).toContain(`Updated${i}`);753 }754 });755 }756 });757758 759760 describe("lock/unlock churn", () => {761 for (const cycles of [1, 2, 3, 5, 10]) {762 it(`${cycles} lock/unlock cycles preserve entries`, async () => {763 const vault = await import("./vault");764 await vault.createVault("test-pass-12345");765 await vault.addEntry({766 title: "Persistent",767 username: "u",768 password: "p",769 url: "https://e.com",770 type: "login",771 });772 for (let i = 0; i < cycles; i++) {773 vault.lock();774 const r = await dispatch({775 type: "UNLOCK",776 payload: { password: "test-pass-12345" },777 });778 expect(r.ok).toBe(true);779 }780 const list = await dispatch({ type: "GET_ENTRIES" });781 expect((list as { data: unknown[] }).data.length).toBe(1);782 });783 }784 });785786 787788 describe("per-field corruption matrix", () => {789 async function setup() {790 const vault = await import("./vault");791 await vault.createVault("test-pass-12345");792 vault.lock();793 }794795 const fields = [796 "salt",797 "nonce",798 "ciphertext",799 "tag",800 "version",801 ];802 const corruptions = [803 { name: "empty", value: "" },804 { name: "garbage hex", value: "zzzz" },805 { name: "wrong length 1", value: "ab" },806 { name: "wrong length long", value: "ab".repeat(200) },807 { name: "non-string number", value: 42 as unknown as string },808 { name: "non-string null", value: null as unknown as string },809 ];810 for (const field of fields) {811 for (const { name, value } of corruptions) {812 it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {813 await setup();814 const a = storage.peach_vault_a as Record<string, unknown>;815 storage.peach_vault_a = { ...a, [field]: value };816 const r = await dispatch({817 type: "UNLOCK",818 payload: { password: "test-pass-12345" },819 });820 expect(r).toBeDefined();821 822 if (823 field !== "version" ||824 (typeof value !== "string" && value !== null)825 ) {826 expect(r.ok).toBe(false);827 }828 });829 }830 }831 });832833 834835 describe("vault corruption surfaces vault_corrupted error", () => {836 async function setup() {837 const vault = await import("./vault");838 await vault.createVault("test-pass-12345");839 vault.lock();840 }841842 it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {843 await setup();844 const a = storage.peach_vault_a as Record<string, unknown>;845 storage.peach_vault_a = { ...a, version: 42 };846 const r = await dispatch({847 type: "UNLOCK",848 payload: { password: "test-pass-12345" },849 });850 expect(r.ok).toBe(false);851 expect((r as { error?: string }).error).toBe("vault_corrupted");852 expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);853 });854855 it("wrong password (correct schema) does NOT return vault_corrupted", async () => {856 await setup();857 const r = await dispatch({858 type: "UNLOCK",859 payload: { password: "wrong-password" },860 });861 expect(r.ok).toBe(false);862 expect((r as { error?: string }).error).not.toBe("vault_corrupted");863 });864 });865866 867868 describe("add-then-relock churn", () => {869 for (let seed = 0; seed < 20; seed++) {870 it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {871 const vault = await import("./vault");872 await vault.createVault("test-pass-12345");873 for (let i = 0; i < 3; i++) {874 await vault.addEntry({875 title: `seed-${seed}-${i}`,876 username: `u${i}`,877 password: `p${i}`,878 url: `https://e${seed}.com`,879 type: "login",880 });881 }882 vault.lock();883 const r = await dispatch({884 type: "UNLOCK",885 payload: { password: "test-pass-12345" },886 });887 expect(r.ok).toBe(true);888 const list = await dispatch({ type: "GET_ENTRIES" });889 expect((list as { data: unknown[] }).data.length).toBe(3);890 });891 }892 });893894 895896 describe("UNLOCK no-crash matrix", () => {897 const adversarialPayloads = [898 { name: "null password", payload: { password: null as unknown as string } },899 { name: "undefined password", payload: { password: undefined as unknown as string } },900 { name: "number password", payload: { password: 12345 as unknown as string } },901 { name: "bool password", payload: { password: true as unknown as string } },902 { name: "array password", payload: { password: ["a", "b"] as unknown as string } },903 { name: "object password", payload: { password: { x: 1 } as unknown as string } },904 { name: "missing password field", payload: {} as { password: string } },905 { name: "empty password", payload: { password: "" } },906 ];907908 for (const { name, payload } of adversarialPayloads) {909 it(`UNLOCK with ${name} resolves without crash`, async () => {910 const vault = await import("./vault");911 await vault.createVault("test-pass-12345");912 vault.lock();913 const r = await dispatch({ type: "UNLOCK", payload });914 expect(r).toBeDefined();915 });916 }917 });918});