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 getURL: (path = "") => `chrome-extension://peach-extension/${path}`,60 onMessage: {61 addListener: vi.fn((fn: MessageListener) => {62 listenerSink.listener = fn;63 }),64 },65 sendMessage: vi.fn(async () => ({ ok: true })),66 },67 tabs: {68 query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {69 if (typeof cb === "function") {70 cb([{ id: 1 }]);71 return;72 }73 return Promise.resolve([{ id: 1 }]);74 }),75 sendMessage: vi.fn(async () => ({ ok: true })),76 },77 storage: {78 local: {79 get: vi.fn(async (key?: string | string[]) => {80 if (!key) return storage;81 if (typeof key === "string") return { [key]: storage[key] };82 return key.reduce<Record<string, unknown>>((acc, k) => {83 acc[k] = storage[k];84 return acc;85 }, {});86 }),87 set: vi.fn(async (next: Record<string, unknown>) => {88 Object.assign(storage, next);89 }),90 remove: vi.fn(async (keys: string | string[]) => {91 const list = Array.isArray(keys) ? keys : [keys];92 for (const key of list) delete storage[key];93 }),94 },95 session: {96 get: vi.fn(async (key?: string | string[]) => {97 if (!key) return sessionStorage;98 if (typeof key === "string") return { [key]: sessionStorage[key] };99 return key.reduce<Record<string, unknown>>((acc, k) => {100 acc[k] = sessionStorage[k];101 return acc;102 }, {});103 }),104 set: vi.fn(async (next: Record<string, unknown>) => {105 Object.assign(sessionStorage, next);106 }),107 remove: vi.fn(async (keys: string | string[]) => {108 const list = Array.isArray(keys) ? keys : [keys];109 for (const key of list) delete sessionStorage[key];110 }),111 clear: vi.fn(async () => {112 for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];113 }),114 },115 },116 alarms: {117 clear: vi.fn(),118 create: vi.fn(),119 onAlarm: { addListener: vi.fn() },120 },121 idle: {122 setDetectionInterval: vi.fn(),123 onStateChanged: { addListener: vi.fn() },124 },125 scripting: { executeScript: vi.fn(async () => []) },126 } as unknown as typeof chrome;127}128129describe("vault adversarial inputs", () => {130 let storage: StorageShape;131 let sessionStorage: StorageShape;132 const sink: { listener?: MessageListener } = {};133134 beforeEach(async () => {135 vi.resetModules();136 vi.useRealTimers();137 storage = { peach_settings: { breachMonitoring: false } };138 sessionStorage = {};139 sink.listener = undefined;140141 vi.stubGlobal(142 "fetch",143 vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))144 );145 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);146147 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(148 storage,149 sessionStorage,150 sink151 );152153 const messages = await import("./messages");154 messages.setupMessageHandler();155 const vault = await import("./vault");156 await vault.ensureSettingsLoaded();157 });158159 async function dispatch(message: Message): Promise<MessageResponse> {160 if (!sink.listener) throw new Error("listener not registered");161 return new Promise<MessageResponse>((resolve) => {162 const keepOpen = sink.listener!(163 message,164 {165 id: "peach-extension",166 url: "chrome-extension://peach-extension/popup.html",167 } as chrome.runtime.MessageSender,168 resolve169 );170 expect(keepOpen).toBe(true);171 });172 }173174 175176 describe("password boundary values — CREATE_VAULT + UNLOCK round-trips", () => {177 for (const { name, password } of PASSWORD_CASES) {178 it(`CREATE_VAULT with ${name} produces a vault unlockable with same password`, async () => {179 const vault = await import("./vault");180 await vault.createVault(password);181 vault.lock();182 const result = await dispatch({183 type: "UNLOCK",184 payload: { password },185 });186 expect(result.ok).toBe(true);187 });188189 it(`UNLOCK with wrong password after ${name} creation fails (and no crash)`, async () => {190 const vault = await import("./vault");191 await vault.createVault(password);192 vault.lock();193 const wrong = password + "x";194 const result = await dispatch({195 type: "UNLOCK",196 payload: { password: wrong },197 });198 expect(result.ok).toBe(false);199 });200201 it(`UNLOCK with empty after ${name} creation fails`, async () => {202 const vault = await import("./vault");203 await vault.createVault(password);204 vault.lock();205 const result = await dispatch({206 type: "UNLOCK",207 payload: { password: "" },208 });209 210 expect(result.ok).toBe(password === "");211 });212213 it(`re-locking after ${name} unlock cleans up state (IS_UNLOCKED=false)`, async () => {214 const vault = await import("./vault");215 await vault.createVault(password);216 await dispatch({ type: "LOCK" });217 const r = await dispatch({ type: "IS_UNLOCKED" });218 expect(r.ok).toBe(true);219 expect((r as { data: boolean }).data).toBe(false);220 });221 }222 });223224 225226 describe("entry boundary values", () => {227 async function setupUnlocked() {228 const vault = await import("./vault");229 await vault.createVault("test-pass-12345");230 }231232 it("entry with empty title is rejected by sanitizeEntryDraft", async () => {233 234 235 await setupUnlocked();236 const r = await dispatch({237 type: "ADD_ENTRY",238 payload: { title: "", username: "u", password: "test", url: "https://e.com" },239 });240 expect(r.ok).toBe(false);241 });242243 244 it("batch_add_drops_non_https_canary", async () => {245 const vault = await import("./vault");246 await vault.createVault("test-pass-12345");247 const [entry] = await vault.batchAddEntries([248 {249 type: "login",250 title: "Acct",251 username: "u",252 password: "p",253 url: "https://e.com",254 canary: { webhookUrl: "http://evil.example" },255 } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],256 ]);257 expect(entry.canary).toBeUndefined();258 });259260 it("batch_add_filters_oversized_and_nonstring_tags", async () => {261 const vault = await import("./vault");262 await vault.createVault("test-pass-12345");263 const tags = [123, " ", "x".repeat(5000), ...Array(1000).fill("t")];264 const [entry] = await vault.batchAddEntries([265 {266 type: "login",267 title: "Tagged",268 username: "u",269 password: "p",270 url: "https://e.com",271 tags,272 } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],273 ]);274 expect(entry.tags).toBeDefined();275 expect(entry.tags!.length).toBeLessThanOrEqual(255);276 expect(entry.tags!.every((t) => typeof t === "string")).toBe(true);277 expect(entry.tags!.every((t) => t.length <= 256)).toBe(true);278 279 expect(entry.tags).toContain("x".repeat(256));280 });281282 const titleCases = [283 { name: "1 char title", title: "x" },284 { name: "100 char title", title: "x".repeat(100) },285 { name: "1000 char title", title: "x".repeat(1000) },286 { name: "10000 char title", title: "x".repeat(10000) },287 { name: "unicode title", title: "日本語 🔑 Ünïcödé" },288 { name: "emoji title", title: "🔑🔒🛡️🌸" },289 { name: "RTL title", title: "كلمة المرور" },290 { name: "tab in title", title: "Tab\there" },291 { name: "newline in title", title: "Line1\nLine2" },292 { name: "html-looking title", title: "<script>alert(1)</script>" },293 { name: "sql-looking title", title: "' OR '1'='1" },294 { name: "null character in title", title: "before\x00after" },295 { name: "control chars title", title: "\x01\x02\x03\x04" },296 ];297298 for (const { name, title } of titleCases) {299 it(`entry with ${name} is handled without crash`, async () => {300 await setupUnlocked();301 const r = await dispatch({302 type: "ADD_ENTRY",303 payload: { title, username: "u", password: "p", url: "https://e.com" },304 });305 expect(r).toBeDefined();306 });307308 it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {309 await setupUnlocked();310 await dispatch({311 type: "ADD_ENTRY",312 payload: { title, username: "u", password: "p", url: "https://e.com" },313 });314 const list = await dispatch({ type: "GET_ENTRIES" });315 expect(list.ok).toBe(true);316 const entries = (list as { data: Array<{ title: string }> }).data;317 expect(entries.length).toBe(1);318 319 expect(entries[0].title).toBe(title);320 });321 }322323 const urlCases = [324 { name: "https URL", url: "https://github.com" },325 { name: "http URL", url: "http://example.com" },326 { name: "javascript URL", url: "javascript:alert('xss')" },327 { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },328 { name: "file URL", url: "file:///etc/passwd" },329 { name: "empty URL", url: "" },330 { name: "spaces URL", url: " " },331 { name: "ftp URL", url: "ftp://example.com" },332 { name: "IPv6 URL", url: "https://[::1]" },333 { name: "IDN URL", url: "https://日本.jp" },334 { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },335 ];336337 for (const { name, url } of urlCases) {338 it(`entry with ${name} stored as-is`, async () => {339 await setupUnlocked();340 const r = await dispatch({341 type: "ADD_ENTRY",342 payload: { title: "t", username: "u", password: "p", url },343 });344 expect(r.ok).toBe(true);345 });346 }347348 it("100 entries: count consistent", async () => {349 await setupUnlocked();350 for (let i = 0; i < 100; i++) {351 await dispatch({352 type: "ADD_ENTRY",353 payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },354 });355 }356 const list = await dispatch({ type: "GET_ENTRIES" });357 expect((list as { data: unknown[] }).data.length).toBe(100);358 });359360 it("vault remains unlockable after many entries", async () => {361 await setupUnlocked();362 const vault = await import("./vault");363 for (let i = 0; i < 50; i++) {364 await vault.addEntry({365 title: `E${i}`,366 username: "u",367 password: `p${i}`,368 url: "https://e.com",369 type: "login",370 });371 }372 vault.lock();373 const unlock = await dispatch({374 type: "UNLOCK",375 payload: { password: "test-pass-12345" },376 });377 expect(unlock.ok).toBe(true);378 const list = await dispatch({ type: "GET_ENTRIES" });379 expect((list as { data: unknown[] }).data.length).toBe(50);380 });381382 it("add then delete leaves no live entries", async () => {383 await setupUnlocked();384 const r = await dispatch({385 type: "ADD_ENTRY",386 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },387 });388 const id = (r as { data: { id: string } }).data.id;389 await dispatch({ type: "DELETE_ENTRY", payload: { id } });390 const list = await dispatch({ type: "GET_ENTRIES" });391 expect((list as { data: unknown[] }).data.length).toBe(0);392 });393 });394395 396397 describe("concurrent operations", () => {398 async function setupLocked() {399 const vault = await import("./vault");400 await vault.createVault("test-pass-12345");401 vault.lock();402 }403404 it("simultaneous UNLOCK calls leave vault unlocked", async () => {405 await setupLocked();406 const [r1, r2] = await Promise.all([407 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),408 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),409 ]);410 expect(r1.ok || r2.ok).toBe(true);411 const status = await dispatch({ type: "IS_UNLOCKED" });412 expect((status as { data: boolean }).data).toBe(true);413 });414415 it("simultaneous wrong UNLOCKs leave vault locked", async () => {416 await setupLocked();417 await Promise.all([418 dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),419 dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),420 ]);421 const status = await dispatch({ type: "IS_UNLOCKED" });422 expect((status as { data: boolean }).data).toBe(false);423 });424425 it("ADD_ENTRY during LOCK does not corrupt vault", async () => {426 const vault = await import("./vault");427 await vault.createVault("test-pass-12345");428 await Promise.all([429 dispatch({430 type: "ADD_ENTRY",431 payload: { title: "t", username: "u", password: "p", url: "https://e.com" },432 }).catch(() => null),433 dispatch({ type: "LOCK" }),434 ]);435 const unlock = await dispatch({436 type: "UNLOCK",437 payload: { password: "test-pass-12345" },438 });439 expect(unlock.ok).toBe(true);440 });441442 for (const count of [5, 10, 20, 50]) {443 it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {444 const vault = await import("./vault");445 await vault.createVault("test-pass-12345");446 await Promise.all(447 Array.from({ length: count }, (_, i) =>448 dispatch({449 type: "ADD_ENTRY",450 payload: {451 title: `E${i}`,452 username: "u",453 password: "p",454 url: "https://e.com",455 },456 })457 )458 );459 const list = await dispatch({ type: "GET_ENTRIES" });460 expect((list as { data: unknown[] }).data.length).toBe(count);461 });462 }463464 it("interleaved ADD + UPDATE keeps entry counts stable", async () => {465 const vault = await import("./vault");466 await vault.createVault("test-pass-12345");467 const r = await dispatch({468 type: "ADD_ENTRY",469 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },470 });471 const id = (r as { data: { id: string } }).data.id;472 await Promise.all([473 dispatch({474 type: "ADD_ENTRY",475 payload: { title: "y", username: "u", password: "p", url: "https://e.com" },476 }),477 dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),478 dispatch({479 type: "ADD_ENTRY",480 payload: { title: "z", username: "u", password: "p", url: "https://e.com" },481 }),482 ]);483 const list = await dispatch({ type: "GET_ENTRIES" });484 expect((list as { data: unknown[] }).data.length).toBe(3);485 });486 });487488 489490 describe("storage corruption", () => {491 async function setupVaultInStorage() {492 const vault = await import("./vault");493 await vault.createVault("test-pass-12345");494 vault.lock();495 }496497 it("non-base64 slot A: unlock fails gracefully", async () => {498 await setupVaultInStorage();499 storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;500 const r = await dispatch({501 type: "UNLOCK",502 payload: { password: "test-pass-12345" },503 });504 expect(r.ok).toBe(false);505 });506507 it("missing slot A: unlock fails", async () => {508 await setupVaultInStorage();509 delete storage.peach_vault_a;510 const r = await dispatch({511 type: "UNLOCK",512 payload: { password: "test-pass-12345" },513 });514 expect(r.ok).toBe(false);515 });516517 it("missing slot B: unlock still works from slot A", async () => {518 await setupVaultInStorage();519 delete storage.peach_vault_b;520 const r = await dispatch({521 type: "UNLOCK",522 payload: { password: "test-pass-12345" },523 });524 expect(r.ok).toBe(true);525 });526527 it("corrupt ciphertext in slot A: unlock fails", async () => {528 await setupVaultInStorage();529 const a = storage.peach_vault_a as { ciphertext: string };530 storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };531 const r = await dispatch({532 type: "UNLOCK",533 payload: { password: "test-pass-12345" },534 });535 expect(r.ok).toBe(false);536 });537538 it("corrupt salt: unlock fails", async () => {539 await setupVaultInStorage();540 const a = storage.peach_vault_a as { salt: string };541 storage.peach_vault_a = { ...a, salt: "00".repeat(16) };542 const r = await dispatch({543 type: "UNLOCK",544 payload: { password: "test-pass-12345" },545 });546 expect(r.ok).toBe(false);547 });548549 it("corrupt nonce: unlock fails", async () => {550 await setupVaultInStorage();551 const a = storage.peach_vault_a as { nonce: string };552 storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };553 const r = await dispatch({554 type: "UNLOCK",555 payload: { password: "test-pass-12345" },556 });557 expect(r.ok).toBe(false);558 });559560 it("corrupt tag: unlock fails", async () => {561 await setupVaultInStorage();562 const a = storage.peach_vault_a as { tag: string };563 storage.peach_vault_a = { ...a, tag: "00".repeat(16) };564 const r = await dispatch({565 type: "UNLOCK",566 payload: { password: "test-pass-12345" },567 });568 expect(r.ok).toBe(false);569 });570571 it("truncated ciphertext: unlock fails", async () => {572 await setupVaultInStorage();573 const a = storage.peach_vault_a as { ciphertext: string };574 storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };575 const r = await dispatch({576 type: "UNLOCK",577 payload: { password: "test-pass-12345" },578 });579 expect(r.ok).toBe(false);580 });581582 it("empty ciphertext: unlock fails", async () => {583 await setupVaultInStorage();584 const a = storage.peach_vault_a as { ciphertext: string };585 storage.peach_vault_a = { ...a, ciphertext: "" };586 const r = await dispatch({587 type: "UNLOCK",588 payload: { password: "test-pass-12345" },589 });590 expect(r.ok).toBe(false);591 });592593 for (const flipPos of [0, 4, 8, 16, 32, 64]) {594 it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {595 await setupVaultInStorage();596 const a = storage.peach_vault_a as { ciphertext: string };597 if (a.ciphertext.length <= flipPos + 1) return;598 const flipped =599 a.ciphertext.slice(0, flipPos) +600 (a.ciphertext[flipPos] === "0" ? "f" : "0") +601 a.ciphertext.slice(flipPos + 1);602 storage.peach_vault_a = { ...a, ciphertext: flipped };603 const r = await dispatch({604 type: "UNLOCK",605 payload: { password: "test-pass-12345" },606 });607 expect(r.ok).toBe(false);608 });609 }610611 it("after corruption, original storage record still exists (no wipe)", async () => {612 await setupVaultInStorage();613 const a = storage.peach_vault_a as { ciphertext: string };614 storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };615 await dispatch({616 type: "UNLOCK",617 payload: { password: "test-pass-12345" },618 });619 expect(storage.peach_vault_a).toBeDefined();620 });621 });622623 624625 describe("repeated wrong unlock attempts", () => {626 async function setupLocked() {627 const vault = await import("./vault");628 await vault.createVault("test-pass-12345");629 vault.lock();630 }631632 for (let attempt = 1; attempt <= 10; attempt++) {633 it(`attempt ${attempt}: wrong password returns ok=false`, async () => {634 await setupLocked();635 for (let i = 0; i < attempt - 1; i++) {636 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });637 }638 const r = await dispatch({639 type: "UNLOCK",640 payload: { password: "wrong" },641 });642 expect(r.ok).toBe(false);643 });644 }645646 it(647 "vault data is preserved across 25 wrong attempts",648 async () => {649 await setupLocked();650 for (let i = 0; i < 25; i++) {651 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });652 }653 expect(storage.peach_vault_a).toBeTruthy();654 655 const ok = await dispatch({656 type: "UNLOCK",657 payload: { password: "test-pass-12345" },658 });659 expect(ok.ok).toBe(true);660 },661 60_000,662 );663664 it(665 "vault data is preserved across 50 wrong attempts",666 async () => {667 await setupLocked();668 for (let i = 0; i < 50; i++) {669 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });670 }671 expect(storage.peach_vault_a).toBeTruthy();672 },673 120_000,674 );675676 it("successful unlock after wrong attempts produces unlocked state", async () => {677 await setupLocked();678 for (let i = 0; i < 5; i++) {679 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });680 }681 const r = await dispatch({682 type: "UNLOCK",683 payload: { password: "test-pass-12345" },684 });685 expect(r.ok).toBe(true);686 const status = await dispatch({ type: "IS_UNLOCKED" });687 expect((status as { data: boolean }).data).toBe(true);688 });689690 it("incorrect-then-correct: state is unlocked", async () => {691 await setupLocked();692 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });693 const r = await dispatch({694 type: "UNLOCK",695 payload: { password: "test-pass-12345" },696 });697 expect(r.ok).toBe(true);698 });699 });700701 702703 describe("generated random password fuzz round-trips", () => {704 705 const seeded: string[] = [];706 let rng = 0xdeadbeef;707 function next() {708 rng = (rng * 1103515245 + 12345) & 0x7fffffff;709 return rng;710 }711 for (let i = 0; i < 50; i++) {712 const len = (next() % 30) + 4;713 let s = "";714 for (let j = 0; j < len; j++) {715 s += String.fromCharCode((next() % 94) + 33);716 }717 seeded.push(s);718 }719 for (const [i, password] of seeded.entries()) {720 it(`fuzz password #${i} round-trips`, async () => {721 const vault = await import("./vault");722 await vault.createVault(password);723 vault.lock();724 const r = await dispatch({ type: "UNLOCK", payload: { password } });725 expect(r.ok).toBe(true);726 });727728 it(`fuzz password #${i} wrong rejects`, async () => {729 const vault = await import("./vault");730 await vault.createVault(password);731 vault.lock();732 const r = await dispatch({733 type: "UNLOCK",734 payload: { password: password.slice(0, -1) + "Z" },735 });736 expect(r.ok).toBe(false);737 });738 }739 });740741 742743 describe("entry lifecycle parameterized", () => {744 for (const n of [1, 2, 3, 5, 10, 25, 50]) {745 it(`add ${n} entries then delete all leaves no live entries`, async () => {746 const vault = await import("./vault");747 await vault.createVault("test-pass-12345");748 const ids: string[] = [];749 for (let i = 0; i < n; i++) {750 const r = await dispatch({751 type: "ADD_ENTRY",752 payload: {753 title: `E${i}`,754 username: "u",755 password: "p",756 url: "https://e.com",757 },758 });759 ids.push((r as { data: { id: string } }).data.id);760 }761 for (const id of ids) {762 await dispatch({ type: "DELETE_ENTRY", payload: { id } });763 }764 const list = await dispatch({ type: "GET_ENTRIES" });765 expect((list as { data: unknown[] }).data.length).toBe(0);766 });767768 it(`add ${n} entries then update each title`, async () => {769 const vault = await import("./vault");770 await vault.createVault("test-pass-12345");771 const ids: string[] = [];772 for (let i = 0; i < n; i++) {773 const r = await dispatch({774 type: "ADD_ENTRY",775 payload: {776 title: `E${i}`,777 username: "u",778 password: "p",779 url: "https://e.com",780 },781 });782 ids.push((r as { data: { id: string } }).data.id);783 }784 for (const [i, id] of ids.entries()) {785 await dispatch({786 type: "UPDATE_ENTRY",787 payload: { id, patch: { title: `Updated${i}` } },788 });789 }790 const list = await dispatch({ type: "GET_ENTRIES" });791 const titles = (792 list as { data: Array<{ title: string }> }793 ).data.map((e) => e.title);794 for (let i = 0; i < n; i++) {795 expect(titles).toContain(`Updated${i}`);796 }797 });798 }799 });800801 802803 describe("lock/unlock churn", () => {804 for (const cycles of [1, 2, 3, 5, 10]) {805 it(`${cycles} lock/unlock cycles preserve entries`, async () => {806 const vault = await import("./vault");807 await vault.createVault("test-pass-12345");808 await vault.addEntry({809 title: "Persistent",810 username: "u",811 password: "p",812 url: "https://e.com",813 type: "login",814 });815 for (let i = 0; i < cycles; i++) {816 vault.lock();817 const r = await dispatch({818 type: "UNLOCK",819 payload: { password: "test-pass-12345" },820 });821 expect(r.ok).toBe(true);822 }823 const list = await dispatch({ type: "GET_ENTRIES" });824 expect((list as { data: unknown[] }).data.length).toBe(1);825 });826 }827 });828829 830831 describe("per-field corruption matrix", () => {832 async function setup() {833 const vault = await import("./vault");834 await vault.createVault("test-pass-12345");835 vault.lock();836 }837838 const fields = [839 "salt",840 "nonce",841 "ciphertext",842 "tag",843 "version",844 ];845 const corruptions = [846 { name: "empty", value: "" },847 { name: "garbage hex", value: "zzzz" },848 { name: "wrong length 1", value: "ab" },849 { name: "wrong length long", value: "ab".repeat(200) },850 { name: "non-string number", value: 42 as unknown as string },851 { name: "non-string null", value: null as unknown as string },852 ];853 for (const field of fields) {854 for (const { name, value } of corruptions) {855 it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {856 await setup();857 const a = storage.peach_vault_a as Record<string, unknown>;858 storage.peach_vault_a = { ...a, [field]: value };859 const r = await dispatch({860 type: "UNLOCK",861 payload: { password: "test-pass-12345" },862 });863 expect(r).toBeDefined();864 865 if (866 field !== "version" ||867 (typeof value !== "string" && value !== null)868 ) {869 expect(r.ok).toBe(false);870 }871 });872 }873 }874 });875876 877878 describe("vault corruption surfaces vault_corrupted error", () => {879 async function setup() {880 const vault = await import("./vault");881 await vault.createVault("test-pass-12345");882 vault.lock();883 }884885 it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {886 await setup();887 const a = storage.peach_vault_a as Record<string, unknown>;888 storage.peach_vault_a = { ...a, version: 42 };889 const r = await dispatch({890 type: "UNLOCK",891 payload: { password: "test-pass-12345" },892 });893 expect(r.ok).toBe(false);894 expect((r as { error?: string }).error).toBe("vault_corrupted");895 expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);896 });897898 it("wrong password (correct schema) does NOT return vault_corrupted", async () => {899 await setup();900 const r = await dispatch({901 type: "UNLOCK",902 payload: { password: "wrong-password" },903 });904 expect(r.ok).toBe(false);905 expect((r as { error?: string }).error).not.toBe("vault_corrupted");906 });907 });908909 910911 describe("add-then-relock churn", () => {912 for (let seed = 0; seed < 20; seed++) {913 it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {914 const vault = await import("./vault");915 await vault.createVault("test-pass-12345");916 for (let i = 0; i < 3; i++) {917 await vault.addEntry({918 title: `seed-${seed}-${i}`,919 username: `u${i}`,920 password: `p${i}`,921 url: `https://e${seed}.com`,922 type: "login",923 });924 }925 vault.lock();926 const r = await dispatch({927 type: "UNLOCK",928 payload: { password: "test-pass-12345" },929 });930 expect(r.ok).toBe(true);931 const list = await dispatch({ type: "GET_ENTRIES" });932 expect((list as { data: unknown[] }).data.length).toBe(3);933 });934 }935 });936937 938939 describe("UNLOCK no-crash matrix", () => {940 const adversarialPayloads = [941 { name: "null password", payload: { password: null as unknown as string } },942 { name: "undefined password", payload: { password: undefined as unknown as string } },943 { name: "number password", payload: { password: 12345 as unknown as string } },944 { name: "bool password", payload: { password: true as unknown as string } },945 { name: "array password", payload: { password: ["a", "b"] as unknown as string } },946 { name: "object password", payload: { password: { x: 1 } as unknown as string } },947 { name: "missing password field", payload: {} as { password: string } },948 { name: "empty password", payload: { password: "" } },949 ];950951 for (const { name, payload } of adversarialPayloads) {952 it(`UNLOCK with ${name} resolves without crash`, async () => {953 const vault = await import("./vault");954 await vault.createVault("test-pass-12345");955 vault.lock();956 const r = await dispatch({ type: "UNLOCK", payload });957 expect(r).toBeDefined();958 });959 }960 });961});