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 240 it("batch_add_drops_non_https_canary", async () => {241 const vault = await import("./vault");242 await vault.createVault("test-pass-12345");243 const [entry] = await vault.batchAddEntries([244 {245 type: "login",246 title: "Acct",247 username: "u",248 password: "p",249 url: "https://e.com",250 canary: { webhookUrl: "http://evil.example" },251 } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],252 ]);253 expect(entry.canary).toBeUndefined();254 });255256 it("batch_add_filters_oversized_and_nonstring_tags", async () => {257 const vault = await import("./vault");258 await vault.createVault("test-pass-12345");259 const tags = [123, " ", "x".repeat(5000), ...Array(1000).fill("t")];260 const [entry] = await vault.batchAddEntries([261 {262 type: "login",263 title: "Tagged",264 username: "u",265 password: "p",266 url: "https://e.com",267 tags,268 } as unknown as Parameters<typeof vault.batchAddEntries>[0][number],269 ]);270 expect(entry.tags).toBeDefined();271 expect(entry.tags!.length).toBeLessThanOrEqual(255);272 expect(entry.tags!.every((t) => typeof t === "string")).toBe(true);273 expect(entry.tags!.every((t) => t.length <= 256)).toBe(true);274 275 expect(entry.tags).toContain("x".repeat(256));276 });277278 const titleCases = [279 { name: "1 char title", title: "x" },280 { name: "100 char title", title: "x".repeat(100) },281 { name: "1000 char title", title: "x".repeat(1000) },282 { name: "10000 char title", title: "x".repeat(10000) },283 { name: "unicode title", title: "日本語 🔑 Ünïcödé" },284 { name: "emoji title", title: "🔑🔒🛡️🌸" },285 { name: "RTL title", title: "كلمة المرور" },286 { name: "tab in title", title: "Tab\there" },287 { name: "newline in title", title: "Line1\nLine2" },288 { name: "html-looking title", title: "<script>alert(1)</script>" },289 { name: "sql-looking title", title: "' OR '1'='1" },290 { name: "null character in title", title: "before\x00after" },291 { name: "control chars title", title: "\x01\x02\x03\x04" },292 ];293294 for (const { name, title } of titleCases) {295 it(`entry with ${name} is handled without crash`, async () => {296 await setupUnlocked();297 const r = await dispatch({298 type: "ADD_ENTRY",299 payload: { title, username: "u", password: "p", url: "https://e.com" },300 });301 expect(r).toBeDefined();302 });303304 it(`entry with ${name} round-trips title via GET_ENTRIES`, async () => {305 await setupUnlocked();306 await dispatch({307 type: "ADD_ENTRY",308 payload: { title, username: "u", password: "p", url: "https://e.com" },309 });310 const list = await dispatch({ type: "GET_ENTRIES" });311 expect(list.ok).toBe(true);312 const entries = (list as { data: Array<{ title: string }> }).data;313 expect(entries.length).toBe(1);314 315 expect(entries[0].title).toBe(title);316 });317 }318319 const urlCases = [320 { name: "https URL", url: "https://github.com" },321 { name: "http URL", url: "http://example.com" },322 { name: "javascript URL", url: "javascript:alert('xss')" },323 { name: "data URL", url: "data:text/html,<script>alert(1)</script>" },324 { name: "file URL", url: "file:///etc/passwd" },325 { name: "empty URL", url: "" },326 { name: "spaces URL", url: " " },327 { name: "ftp URL", url: "ftp://example.com" },328 { name: "IPv6 URL", url: "https://[::1]" },329 { name: "IDN URL", url: "https://日本.jp" },330 { name: "very long URL", url: "https://example.com/" + "a".repeat(5000) },331 ];332333 for (const { name, url } of urlCases) {334 it(`entry with ${name} stored as-is`, async () => {335 await setupUnlocked();336 const r = await dispatch({337 type: "ADD_ENTRY",338 payload: { title: "t", username: "u", password: "p", url },339 });340 expect(r.ok).toBe(true);341 });342 }343344 it("100 entries: count consistent", async () => {345 await setupUnlocked();346 for (let i = 0; i < 100; i++) {347 await dispatch({348 type: "ADD_ENTRY",349 payload: { title: `E${i}`, username: "u", password: `p${i}`, url: "https://e.com" },350 });351 }352 const list = await dispatch({ type: "GET_ENTRIES" });353 expect((list as { data: unknown[] }).data.length).toBe(100);354 });355356 it("vault remains unlockable after many entries", async () => {357 await setupUnlocked();358 const vault = await import("./vault");359 for (let i = 0; i < 50; i++) {360 await vault.addEntry({361 title: `E${i}`,362 username: "u",363 password: `p${i}`,364 url: "https://e.com",365 type: "login",366 });367 }368 vault.lock();369 const unlock = await dispatch({370 type: "UNLOCK",371 payload: { password: "test-pass-12345" },372 });373 expect(unlock.ok).toBe(true);374 const list = await dispatch({ type: "GET_ENTRIES" });375 expect((list as { data: unknown[] }).data.length).toBe(50);376 });377378 it("add then delete leaves no live entries", async () => {379 await setupUnlocked();380 const r = await dispatch({381 type: "ADD_ENTRY",382 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },383 });384 const id = (r as { data: { id: string } }).data.id;385 await dispatch({ type: "DELETE_ENTRY", payload: { id } });386 const list = await dispatch({ type: "GET_ENTRIES" });387 expect((list as { data: unknown[] }).data.length).toBe(0);388 });389 });390391 392393 describe("concurrent operations", () => {394 async function setupLocked() {395 const vault = await import("./vault");396 await vault.createVault("test-pass-12345");397 vault.lock();398 }399400 it("simultaneous UNLOCK calls leave vault unlocked", async () => {401 await setupLocked();402 const [r1, r2] = await Promise.all([403 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),404 dispatch({ type: "UNLOCK", payload: { password: "test-pass-12345" } }),405 ]);406 expect(r1.ok || r2.ok).toBe(true);407 const status = await dispatch({ type: "IS_UNLOCKED" });408 expect((status as { data: boolean }).data).toBe(true);409 });410411 it("simultaneous wrong UNLOCKs leave vault locked", async () => {412 await setupLocked();413 await Promise.all([414 dispatch({ type: "UNLOCK", payload: { password: "wrong-1" } }),415 dispatch({ type: "UNLOCK", payload: { password: "wrong-2" } }),416 ]);417 const status = await dispatch({ type: "IS_UNLOCKED" });418 expect((status as { data: boolean }).data).toBe(false);419 });420421 it("ADD_ENTRY during LOCK does not corrupt vault", async () => {422 const vault = await import("./vault");423 await vault.createVault("test-pass-12345");424 await Promise.all([425 dispatch({426 type: "ADD_ENTRY",427 payload: { title: "t", username: "u", password: "p", url: "https://e.com" },428 }).catch(() => null),429 dispatch({ type: "LOCK" }),430 ]);431 const unlock = await dispatch({432 type: "UNLOCK",433 payload: { password: "test-pass-12345" },434 });435 expect(unlock.ok).toBe(true);436 });437438 for (const count of [5, 10, 20, 50]) {439 it(`simultaneous ADD_ENTRY x ${count} produce that many entries`, async () => {440 const vault = await import("./vault");441 await vault.createVault("test-pass-12345");442 await Promise.all(443 Array.from({ length: count }, (_, i) =>444 dispatch({445 type: "ADD_ENTRY",446 payload: {447 title: `E${i}`,448 username: "u",449 password: "p",450 url: "https://e.com",451 },452 })453 )454 );455 const list = await dispatch({ type: "GET_ENTRIES" });456 expect((list as { data: unknown[] }).data.length).toBe(count);457 });458 }459460 it("interleaved ADD + UPDATE keeps entry counts stable", async () => {461 const vault = await import("./vault");462 await vault.createVault("test-pass-12345");463 const r = await dispatch({464 type: "ADD_ENTRY",465 payload: { title: "x", username: "u", password: "p", url: "https://e.com" },466 });467 const id = (r as { data: { id: string } }).data.id;468 await Promise.all([469 dispatch({470 type: "ADD_ENTRY",471 payload: { title: "y", username: "u", password: "p", url: "https://e.com" },472 }),473 dispatch({ type: "UPDATE_ENTRY", payload: { id, patch: { username: "v" } } }),474 dispatch({475 type: "ADD_ENTRY",476 payload: { title: "z", username: "u", password: "p", url: "https://e.com" },477 }),478 ]);479 const list = await dispatch({ type: "GET_ENTRIES" });480 expect((list as { data: unknown[] }).data.length).toBe(3);481 });482 });483484 485486 describe("storage corruption", () => {487 async function setupVaultInStorage() {488 const vault = await import("./vault");489 await vault.createVault("test-pass-12345");490 vault.lock();491 }492493 it("non-base64 slot A: unlock fails gracefully", async () => {494 await setupVaultInStorage();495 storage.peach_vault_a = "not-a-valid-stored-vault" as unknown;496 const r = await dispatch({497 type: "UNLOCK",498 payload: { password: "test-pass-12345" },499 });500 expect(r.ok).toBe(false);501 });502503 it("missing slot A: unlock fails", async () => {504 await setupVaultInStorage();505 delete storage.peach_vault_a;506 const r = await dispatch({507 type: "UNLOCK",508 payload: { password: "test-pass-12345" },509 });510 expect(r.ok).toBe(false);511 });512513 it("missing slot B: unlock still works from slot A", async () => {514 await setupVaultInStorage();515 delete storage.peach_vault_b;516 const r = await dispatch({517 type: "UNLOCK",518 payload: { password: "test-pass-12345" },519 });520 expect(r.ok).toBe(true);521 });522523 it("corrupt ciphertext in slot A: unlock fails", async () => {524 await setupVaultInStorage();525 const a = storage.peach_vault_a as { ciphertext: string };526 storage.peach_vault_a = { ...a, ciphertext: "AAAA" + a.ciphertext.slice(4) };527 const r = await dispatch({528 type: "UNLOCK",529 payload: { password: "test-pass-12345" },530 });531 expect(r.ok).toBe(false);532 });533534 it("corrupt salt: unlock fails", async () => {535 await setupVaultInStorage();536 const a = storage.peach_vault_a as { salt: string };537 storage.peach_vault_a = { ...a, salt: "00".repeat(16) };538 const r = await dispatch({539 type: "UNLOCK",540 payload: { password: "test-pass-12345" },541 });542 expect(r.ok).toBe(false);543 });544545 it("corrupt nonce: unlock fails", async () => {546 await setupVaultInStorage();547 const a = storage.peach_vault_a as { nonce: string };548 storage.peach_vault_a = { ...a, nonce: "ff".repeat(12) };549 const r = await dispatch({550 type: "UNLOCK",551 payload: { password: "test-pass-12345" },552 });553 expect(r.ok).toBe(false);554 });555556 it("corrupt tag: unlock fails", async () => {557 await setupVaultInStorage();558 const a = storage.peach_vault_a as { tag: string };559 storage.peach_vault_a = { ...a, tag: "00".repeat(16) };560 const r = await dispatch({561 type: "UNLOCK",562 payload: { password: "test-pass-12345" },563 });564 expect(r.ok).toBe(false);565 });566567 it("truncated ciphertext: unlock fails", async () => {568 await setupVaultInStorage();569 const a = storage.peach_vault_a as { ciphertext: string };570 storage.peach_vault_a = { ...a, ciphertext: a.ciphertext.slice(0, 20) };571 const r = await dispatch({572 type: "UNLOCK",573 payload: { password: "test-pass-12345" },574 });575 expect(r.ok).toBe(false);576 });577578 it("empty ciphertext: unlock fails", async () => {579 await setupVaultInStorage();580 const a = storage.peach_vault_a as { ciphertext: string };581 storage.peach_vault_a = { ...a, ciphertext: "" };582 const r = await dispatch({583 type: "UNLOCK",584 payload: { password: "test-pass-12345" },585 });586 expect(r.ok).toBe(false);587 });588589 for (const flipPos of [0, 4, 8, 16, 32, 64]) {590 it(`bit flip in ciphertext at offset ${flipPos}: unlock fails`, async () => {591 await setupVaultInStorage();592 const a = storage.peach_vault_a as { ciphertext: string };593 if (a.ciphertext.length <= flipPos + 1) return;594 const flipped =595 a.ciphertext.slice(0, flipPos) +596 (a.ciphertext[flipPos] === "0" ? "f" : "0") +597 a.ciphertext.slice(flipPos + 1);598 storage.peach_vault_a = { ...a, ciphertext: flipped };599 const r = await dispatch({600 type: "UNLOCK",601 payload: { password: "test-pass-12345" },602 });603 expect(r.ok).toBe(false);604 });605 }606607 it("after corruption, original storage record still exists (no wipe)", async () => {608 await setupVaultInStorage();609 const a = storage.peach_vault_a as { ciphertext: string };610 storage.peach_vault_a = { ...a, ciphertext: "00".repeat(64) };611 await dispatch({612 type: "UNLOCK",613 payload: { password: "test-pass-12345" },614 });615 expect(storage.peach_vault_a).toBeDefined();616 });617 });618619 620621 describe("repeated wrong unlock attempts", () => {622 async function setupLocked() {623 const vault = await import("./vault");624 await vault.createVault("test-pass-12345");625 vault.lock();626 }627628 for (let attempt = 1; attempt <= 10; attempt++) {629 it(`attempt ${attempt}: wrong password returns ok=false`, async () => {630 await setupLocked();631 for (let i = 0; i < attempt - 1; i++) {632 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });633 }634 const r = await dispatch({635 type: "UNLOCK",636 payload: { password: "wrong" },637 });638 expect(r.ok).toBe(false);639 });640 }641642 it(643 "vault data is preserved across 25 wrong attempts",644 async () => {645 await setupLocked();646 for (let i = 0; i < 25; i++) {647 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });648 }649 expect(storage.peach_vault_a).toBeTruthy();650 651 const ok = await dispatch({652 type: "UNLOCK",653 payload: { password: "test-pass-12345" },654 });655 expect(ok.ok).toBe(true);656 },657 60_000,658 );659660 it(661 "vault data is preserved across 50 wrong attempts",662 async () => {663 await setupLocked();664 for (let i = 0; i < 50; i++) {665 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });666 }667 expect(storage.peach_vault_a).toBeTruthy();668 },669 120_000,670 );671672 it("successful unlock after wrong attempts produces unlocked state", async () => {673 await setupLocked();674 for (let i = 0; i < 5; i++) {675 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });676 }677 const r = await dispatch({678 type: "UNLOCK",679 payload: { password: "test-pass-12345" },680 });681 expect(r.ok).toBe(true);682 const status = await dispatch({ type: "IS_UNLOCKED" });683 expect((status as { data: boolean }).data).toBe(true);684 });685686 it("incorrect-then-correct: state is unlocked", async () => {687 await setupLocked();688 await dispatch({ type: "UNLOCK", payload: { password: "wrong" } });689 const r = await dispatch({690 type: "UNLOCK",691 payload: { password: "test-pass-12345" },692 });693 expect(r.ok).toBe(true);694 });695 });696697 698699 describe("generated random password fuzz round-trips", () => {700 701 const seeded: string[] = [];702 let rng = 0xdeadbeef;703 function next() {704 rng = (rng * 1103515245 + 12345) & 0x7fffffff;705 return rng;706 }707 for (let i = 0; i < 50; i++) {708 const len = (next() % 30) + 4;709 let s = "";710 for (let j = 0; j < len; j++) {711 s += String.fromCharCode((next() % 94) + 33);712 }713 seeded.push(s);714 }715 for (const [i, password] of seeded.entries()) {716 it(`fuzz password #${i} round-trips`, async () => {717 const vault = await import("./vault");718 await vault.createVault(password);719 vault.lock();720 const r = await dispatch({ type: "UNLOCK", payload: { password } });721 expect(r.ok).toBe(true);722 });723724 it(`fuzz password #${i} wrong rejects`, async () => {725 const vault = await import("./vault");726 await vault.createVault(password);727 vault.lock();728 const r = await dispatch({729 type: "UNLOCK",730 payload: { password: password.slice(0, -1) + "Z" },731 });732 expect(r.ok).toBe(false);733 });734 }735 });736737 738739 describe("entry lifecycle parameterized", () => {740 for (const n of [1, 2, 3, 5, 10, 25, 50]) {741 it(`add ${n} entries then delete all leaves no live entries`, async () => {742 const vault = await import("./vault");743 await vault.createVault("test-pass-12345");744 const ids: string[] = [];745 for (let i = 0; i < n; i++) {746 const r = await dispatch({747 type: "ADD_ENTRY",748 payload: {749 title: `E${i}`,750 username: "u",751 password: "p",752 url: "https://e.com",753 },754 });755 ids.push((r as { data: { id: string } }).data.id);756 }757 for (const id of ids) {758 await dispatch({ type: "DELETE_ENTRY", payload: { id } });759 }760 const list = await dispatch({ type: "GET_ENTRIES" });761 expect((list as { data: unknown[] }).data.length).toBe(0);762 });763764 it(`add ${n} entries then update each title`, async () => {765 const vault = await import("./vault");766 await vault.createVault("test-pass-12345");767 const ids: string[] = [];768 for (let i = 0; i < n; i++) {769 const r = await dispatch({770 type: "ADD_ENTRY",771 payload: {772 title: `E${i}`,773 username: "u",774 password: "p",775 url: "https://e.com",776 },777 });778 ids.push((r as { data: { id: string } }).data.id);779 }780 for (const [i, id] of ids.entries()) {781 await dispatch({782 type: "UPDATE_ENTRY",783 payload: { id, patch: { title: `Updated${i}` } },784 });785 }786 const list = await dispatch({ type: "GET_ENTRIES" });787 const titles = (788 list as { data: Array<{ title: string }> }789 ).data.map((e) => e.title);790 for (let i = 0; i < n; i++) {791 expect(titles).toContain(`Updated${i}`);792 }793 });794 }795 });796797 798799 describe("lock/unlock churn", () => {800 for (const cycles of [1, 2, 3, 5, 10]) {801 it(`${cycles} lock/unlock cycles preserve entries`, async () => {802 const vault = await import("./vault");803 await vault.createVault("test-pass-12345");804 await vault.addEntry({805 title: "Persistent",806 username: "u",807 password: "p",808 url: "https://e.com",809 type: "login",810 });811 for (let i = 0; i < cycles; i++) {812 vault.lock();813 const r = await dispatch({814 type: "UNLOCK",815 payload: { password: "test-pass-12345" },816 });817 expect(r.ok).toBe(true);818 }819 const list = await dispatch({ type: "GET_ENTRIES" });820 expect((list as { data: unknown[] }).data.length).toBe(1);821 });822 }823 });824825 826827 describe("per-field corruption matrix", () => {828 async function setup() {829 const vault = await import("./vault");830 await vault.createVault("test-pass-12345");831 vault.lock();832 }833834 const fields = [835 "salt",836 "nonce",837 "ciphertext",838 "tag",839 "version",840 ];841 const corruptions = [842 { name: "empty", value: "" },843 { name: "garbage hex", value: "zzzz" },844 { name: "wrong length 1", value: "ab" },845 { name: "wrong length long", value: "ab".repeat(200) },846 { name: "non-string number", value: 42 as unknown as string },847 { name: "non-string null", value: null as unknown as string },848 ];849 for (const field of fields) {850 for (const { name, value } of corruptions) {851 it(`field=${field} corruption=${name}: unlock fails or response defined`, async () => {852 await setup();853 const a = storage.peach_vault_a as Record<string, unknown>;854 storage.peach_vault_a = { ...a, [field]: value };855 const r = await dispatch({856 type: "UNLOCK",857 payload: { password: "test-pass-12345" },858 });859 expect(r).toBeDefined();860 861 if (862 field !== "version" ||863 (typeof value !== "string" && value !== null)864 ) {865 expect(r.ok).toBe(false);866 }867 });868 }869 }870 });871872 873874 describe("vault corruption surfaces vault_corrupted error", () => {875 async function setup() {876 const vault = await import("./vault");877 await vault.createVault("test-pass-12345");878 vault.lock();879 }880881 it("schema-invalid slot (version=42) returns vault_corrupted with canRestoreFromHistory", async () => {882 await setup();883 const a = storage.peach_vault_a as Record<string, unknown>;884 storage.peach_vault_a = { ...a, version: 42 };885 const r = await dispatch({886 type: "UNLOCK",887 payload: { password: "test-pass-12345" },888 });889 expect(r.ok).toBe(false);890 expect((r as { error?: string }).error).toBe("vault_corrupted");891 expect((r as { canRestoreFromHistory?: boolean }).canRestoreFromHistory).toBe(true);892 });893894 it("wrong password (correct schema) does NOT return vault_corrupted", async () => {895 await setup();896 const r = await dispatch({897 type: "UNLOCK",898 payload: { password: "wrong-password" },899 });900 expect(r.ok).toBe(false);901 expect((r as { error?: string }).error).not.toBe("vault_corrupted");902 });903 });904905 906907 describe("add-then-relock churn", () => {908 for (let seed = 0; seed < 20; seed++) {909 it(`seed ${seed}: add 3 entries with seeded titles survives lock/unlock`, async () => {910 const vault = await import("./vault");911 await vault.createVault("test-pass-12345");912 for (let i = 0; i < 3; i++) {913 await vault.addEntry({914 title: `seed-${seed}-${i}`,915 username: `u${i}`,916 password: `p${i}`,917 url: `https://e${seed}.com`,918 type: "login",919 });920 }921 vault.lock();922 const r = await dispatch({923 type: "UNLOCK",924 payload: { password: "test-pass-12345" },925 });926 expect(r.ok).toBe(true);927 const list = await dispatch({ type: "GET_ENTRIES" });928 expect((list as { data: unknown[] }).data.length).toBe(3);929 });930 }931 });932933 934935 describe("UNLOCK no-crash matrix", () => {936 const adversarialPayloads = [937 { name: "null password", payload: { password: null as unknown as string } },938 { name: "undefined password", payload: { password: undefined as unknown as string } },939 { name: "number password", payload: { password: 12345 as unknown as string } },940 { name: "bool password", payload: { password: true as unknown as string } },941 { name: "array password", payload: { password: ["a", "b"] as unknown as string } },942 { name: "object password", payload: { password: { x: 1 } as unknown as string } },943 { name: "missing password field", payload: {} as { password: string } },944 { name: "empty password", payload: { password: "" } },945 ];946947 for (const { name, payload } of adversarialPayloads) {948 it(`UNLOCK with ${name} resolves without crash`, async () => {949 const vault = await import("./vault");950 await vault.createVault("test-pass-12345");951 vault.lock();952 const r = await dispatch({ type: "UNLOCK", payload });953 expect(r).toBeDefined();954 });955 }956 });957});