d65de1e59cb3
test: integrate priority 3 test files T-025 T-030 T-037 + clipboard write-failure guard (v3.9.8)
- time
- 2026-06-12 06:11 UTC · 4 months ago
- machine
- koh2
- agent
- claude-sonnet-4-6
- session
- test expansion integration
- parent
- 12e652193472
↓ peach-browser-d65de1e5.tar.gzatlas at this save
files changed4 files
src/background/clipboard.double-failure.test.ts+170 −0src/background/clipboard.ts+3 −1src/background/ml.predictbatch.test.ts+185 −0src/shared/generate.empty-pool.test.ts+130 −0src/background/clipboard.double-failure.test.ts+170 −0
@@ -0,0 +1,170 @@1+// T-037: clipboard double-failure / alreadyWritten path2+//3+// Why these tests exist: copyToClipboard in clipboard.ts has two untested paths:4+//5+// 1. `alreadyWritten=true` skips the writeToClipboard() call.6+// The popup/content caller may have already written to the clipboard from a7+// focused document with a fresh user gesture (more reliable than the SW's8+// executeScript path). When `alreadyWritten=true`, the SW should only9+// schedule the auto-clear alarm — NOT attempt a second write that would10+// fail or overwrite the user's clipboard.11+//12+// 2. Both clipboard write paths fail (chrome.scripting.executeScript + direct13+// navigator.clipboard.writeText). The function must not throw; the alarm14+// is still scheduled so the clear attempt fires on a retry.15+//16+// The existing clipboard.test.ts covers the happy path (write + clear).17+// These tests cover the edge paths that complete the audit finding HV-2.18+19+import { beforeEach, describe, expect, it, vi } from "vitest";20+21+type StorageShape = Record<string, unknown>;22+23+describe("T-037: clipboard.ts alreadyWritten and double-failure paths", () => {24+ let storage: StorageShape;25+ let sessionStorage: StorageShape;26+27+ beforeEach(async () => {28+ vi.resetModules();29+ storage = {};30+ sessionStorage = {};31+32+ const alarmListeners: Array<(alarm: { name: string }) => void> = [];33+ const createdAlarms: Array<{ name: string; opts: Record<string, unknown> }> = [];34+35+ const chromeMock = {36+ storage: {37+ local: {38+ get: vi.fn(async (key?: string | string[]) => {39+ if (!key) return { ...storage };40+ if (typeof key === "string") return { [key]: storage[key] };41+ return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(42+ (acc, k) => { acc[k] = storage[k]; return acc; },43+ {},44+ );45+ }),46+ set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),47+ remove: vi.fn(async () => {}),48+ },49+ session: {50+ get: vi.fn(async (key?: string | string[]) => {51+ if (!key) return { ...sessionStorage };52+ if (typeof key === "string") return { [key]: sessionStorage[key] };53+ return (Array.isArray(key) ? key : [key]).reduce<StorageShape>(54+ (acc, k) => { acc[k] = sessionStorage[k]; return acc; },55+ {},56+ );57+ }),58+ set: vi.fn(async (next: StorageShape) => { Object.assign(sessionStorage, next); }),59+ remove: vi.fn(async () => {}),60+ },61+ },62+ alarms: {63+ clear: vi.fn(async () => true),64+ create: vi.fn(async (name: string, opts: Record<string, unknown>) => {65+ createdAlarms.push({ name, opts });66+ }),67+ onAlarm: {68+ addListener: vi.fn((cb: (alarm: { name: string }) => void) => {69+ alarmListeners.push(cb);70+ }),71+ },72+ },73+ tabs: {74+ query: vi.fn(async () => []),75+ },76+ scripting: {77+ executeScript: vi.fn(async () => { throw new Error("executeScript failed"); }),78+ },79+ } as unknown as typeof chrome;80+81+ (globalThis as Record<string, unknown>).chrome = chromeMock;82+ (globalThis as Record<string, unknown>)._createdAlarms = createdAlarms;83+84+ // Mock navigator.clipboard for this test suite85+ Object.defineProperty(globalThis, "navigator", {86+ value: { clipboard: { writeText: vi.fn() } },87+ configurable: true,88+ });89+ });90+91+ // T-037a: alreadyWritten=true → writeToClipboard is NOT called.92+ // The caller has already written the clipboard via a user-gesture path.93+ // The background script must only schedule the auto-clear alarm.94+ //95+ // Bug this catches: ignoring `alreadyWritten` flag would double-write,96+ // potentially overwriting the clipboard with the previous value if97+ // executeScript fails and falls through to navigator.clipboard.98+ it("T-037a: alreadyWritten=true → navigator.clipboard.writeText NOT called", async () => {99+ const { copyToClipboard } = await import("./clipboard");100+101+ const writeTextMock = vi.fn(async () => {});102+ Object.defineProperty(globalThis.navigator, "clipboard", {103+ value: { writeText: writeTextMock },104+ configurable: true,105+ });106+107+ await copyToClipboard("secret-value", "Password", true /* alreadyWritten */);108+109+ // navigator.clipboard.writeText must NOT be called (the caller already wrote)110+ expect(writeTextMock).not.toHaveBeenCalled();111+ });112+113+ // T-037b: alreadyWritten=true → the auto-clear alarm IS still scheduled.114+ // Even when we skip writing, the alarm must fire to clear the clipboard later.115+ it("T-037b: alreadyWritten=true → alarm is still created for auto-clear", async () => {116+ const { copyToClipboard } = await import("./clipboard");117+ const alarmsMock = chrome.alarms as { create: ReturnType<typeof vi.fn> };118+119+ await copyToClipboard("secret-value", "Password", true);120+121+ // The alarm must have been created122+ expect(alarmsMock.create).toHaveBeenCalled();123+ const alarmName = (alarmsMock.create.mock.calls[0] as [string, unknown])[0];124+ expect(alarmName).toBe("peach-clipboard-clear-current");125+ });126+127+ // T-037c: both clipboard write paths fail → copyToClipboard does NOT throw.128+ // executeScript fails (tab query returns no tabs or scripting throws) AND129+ // navigator.clipboard.writeText also throws. The alarm must still be130+ // scheduled so the secret is not silently left without a clear attempt.131+ //132+ // Bug this catches: an uncaught error in writeToClipboard propagating up133+ // through copyToClipboard would leave the alarm unscheduled — the clipboard134+ // would never be cleared.135+ it("T-037c: both write paths fail → copyToClipboard does not throw (alarm still scheduled)", async () => {136+ // Make navigator.clipboard.writeText throw137+ Object.defineProperty(globalThis.navigator, "clipboard", {138+ value: { writeText: vi.fn(async () => { throw new Error("clipboard API unavailable"); }) },139+ configurable: true,140+ });141+ // executeScript already fails (no tabs in mock)142+143+ const { copyToClipboard } = await import("./clipboard");144+ const alarmsMock = chrome.alarms as { create: ReturnType<typeof vi.fn> };145+146+ // Must not throw even when both paths fail147+ await expect(copyToClipboard("secret", "Password", false)).resolves.toBeUndefined();148+149+ // Alarm must still be scheduled150+ expect(alarmsMock.create).toHaveBeenCalled();151+ });152+153+ // T-037d: alreadyWritten=false (default) → write IS attempted.154+ // Verifies the normal path still works after the alreadyWritten tests.155+ it("T-037d: alreadyWritten=false (default) → clipboard write is attempted", async () => {156+ const writeTextMock = vi.fn(async () => {});157+ Object.defineProperty(globalThis.navigator, "clipboard", {158+ value: { writeText: writeTextMock },159+ configurable: true,160+ });161+162+ const { copyToClipboard } = await import("./clipboard");163+164+ await copyToClipboard("secret", "Password", false);165+166+ // Either executeScript OR navigator.clipboard was used.167+ // Since executeScript fails (no tab), fallback goes to navigator.clipboard.168+ expect(writeTextMock).toHaveBeenCalledWith("secret");169+ });170+});src/background/clipboard.ts+3 −1
@@ -78,7 +78,9 @@7878 // than the SW's `executeScript` into the active tab. When that happens, we7979 // only need to schedule the auto-clear here.8080 if (!alreadyWritten) {81- await writeToClipboard(text);81+ // Best-effort: a clipboard write failure (e.g. no active tab, navigator.clipboard82+ // unavailable in SW context) must not prevent the clear alarm from being scheduled.83+ await writeToClipboard(text).catch(() => {});8284 }8385 8486 const settingsSeconds = getSettings().clipboardClearSeconds;src/background/ml.predictbatch.test.ts+185 −0
@@ -0,0 +1,185 @@1+// T-030: ml.ts predictBatch — batch sizes, softmax stability with extreme weights2+//3+// Why these tests exist: `predictBatch` in background/ml.ts is the ML inference4+// entry point called by the autofill orchestration layer. It is also the5+// critical path that runs on every form interaction. Two properties are untested:6+//7+// 1. Batch size handling: empty arrays, single items, and large batches8+// (100+ fields — enough to cover entire SPA page loads).9+//10+// 2. Softmax numerical stability: the softmax implementation uses the11+// max-subtraction trick (`x - max` before exp()) to prevent overflow for12+// extreme logit values. Without this, extremely large logits (e.g., 1000)13+// would produce `Infinity` in Math.exp(1000), making the confidence NaN.14+// This is the standard numerical stability fix and should be tested with15+// stress-case feature vectors.16+//17+// Note: the model weights (W, B) are baked in. We test the inference contract18+// (output shape, probability constraints) not the model accuracy.19+20+import { describe, expect, it } from "vitest";21+import { predictBatch } from "./ml";22+import type { MLFieldFeatures, FieldPrediction, FieldType } from "../shared/ml";23+24+// ---------------------------------------------------------------------------25+// Feature vector factory26+// ---------------------------------------------------------------------------27+28+// Builds a zero-vector feature set (no signals → model returns "none" or29+// uncertain distribution).30+function zeroFeatures(): MLFieldFeatures {31+ return {32+ type_text: 0, type_email: 0, type_password: 0, type_tel: 0,33+ type_number: 0, type_search: 0, type_url: 0, type_other: 0,34+ auto_username: 0, auto_email: 0, auto_current_password: 0,35+ auto_new_password: 0, auto_one_time_code: 0, auto_off: 0, auto_other: 0,36+ name_has_user: 0, name_has_login: 0, name_has_email: 0, name_has_pass: 0,37+ name_length: 0,38+ id_has_user: 0, id_has_login: 0, id_has_email: 0, id_has_pass: 0,39+ id_length: 0,40+ placeholder_has_user: 0, placeholder_has_email: 0, placeholder_has_pass: 0,41+ placeholder_length: 0,42+ aria_label_has_user: 0, aria_label_has_email: 0, aria_label_has_pass: 0,43+ aria_label_length: 0,44+ parent_is_form: 0, parent_is_div: 0, parent_is_section: 0,45+ sibling_count: 0, has_password_sibling: 0, has_email_sibling: 0,46+ form_has_submit: 0, form_action_has_login: 0,47+ is_required: 0, has_placeholder: 0, has_aria_label: 0,48+ inputmode_numeric: 0,49+ };50+}51+52+// Builds features that strongly signal a password field.53+function passwordFeatures(): MLFieldFeatures {54+ return {55+ ...zeroFeatures(),56+ type_password: 1,57+ auto_current_password: 1,58+ name_has_pass: 1,59+ id_has_pass: 1,60+ parent_is_form: 1,61+ form_has_submit: 1,62+ };63+}64+65+const VALID_TYPES: FieldType[] = ["username", "password", "email", "totp", "none"];66+67+function assertValidPrediction(p: FieldPrediction): void {68+ expect(VALID_TYPES).toContain(p.type);69+ expect(p.confidence).toBeGreaterThanOrEqual(0);70+ expect(p.confidence).toBeLessThanOrEqual(1);71+ expect(Number.isFinite(p.confidence)).toBe(true);72+ expect(Number.isNaN(p.confidence)).toBe(false);73+74+ let sumProb = 0;75+ for (const t of VALID_TYPES) {76+ const prob = p.probabilities[t];77+ expect(Number.isFinite(prob)).toBe(true);78+ expect(prob).toBeGreaterThanOrEqual(0);79+ sumProb += prob;80+ }81+ // Probabilities must sum to ~1.0 (floating point tolerance)82+ expect(Math.abs(sumProb - 1.0)).toBeLessThan(1e-9);83+}84+85+// ---------------------------------------------------------------------------86+// Tests87+// ---------------------------------------------------------------------------88+89+describe("T-030: predictBatch batch sizes", () => {90+ // T-030a: empty array → returns empty array, no error.91+ it("T-030a: predictBatch([]) returns empty array", () => {92+ const result = predictBatch([]);93+ expect(Array.isArray(result)).toBe(true);94+ expect(result.length).toBe(0);95+ });96+97+ // T-030b: single feature vector → returns exactly one prediction.98+ it("T-030b: predictBatch([single]) returns exactly 1 prediction", () => {99+ const result = predictBatch([zeroFeatures()]);100+ expect(result.length).toBe(1);101+ assertValidPrediction(result[0]!);102+ });103+104+ // T-030c: batch of 100 feature vectors → returns exactly 100 predictions.105+ // 100 fields covers a large SPA page load (e.g., a checkout form with106+ // multiple steps rendered simultaneously).107+ it("T-030c: predictBatch(100 vectors) returns 100 predictions", () => {108+ const features = Array.from({ length: 100 }, () => zeroFeatures());109+ const result = predictBatch(features);110+ expect(result.length).toBe(100);111+ for (const p of result) {112+ assertValidPrediction(p);113+ }114+ });115+116+ // T-030d: password-signal features produce a prediction for "password" type.117+ // This is not a model accuracy test — it just verifies that strong password118+ // signals produce a non-zero confidence for the "password" class.119+ it("T-030d: password-signal features → confidence for password is highest or meaningful", () => {120+ const result = predictBatch([passwordFeatures()]);121+ expect(result.length).toBe(1);122+ assertValidPrediction(result[0]!);123+ // With strong password features, the password probability should be notable124+ expect(result[0]!.probabilities.password).toBeGreaterThan(0);125+ });126+});127+128+describe("T-030: predictBatch softmax numerical stability", () => {129+ // T-030e: extreme positive logit values (all features = 1000) → softmax must130+ // not produce NaN or Infinity. The max-subtraction trick prevents overflow.131+ //132+ // Bug this catches: removing the `x - max` subtraction in softmax would cause133+ // Math.exp(1000) = Infinity, making all probabilities NaN and confidence NaN.134+ it("T-030e: extreme positive feature values → no NaN or Infinity in output", () => {135+ const extremeFeatures: MLFieldFeatures = {} as MLFieldFeatures;136+ for (const key of Object.keys(zeroFeatures()) as (keyof MLFieldFeatures)[]) {137+ (extremeFeatures as Record<string, number>)[key] = 1000;138+ }139+140+ const result = predictBatch([extremeFeatures]);141+ assertValidPrediction(result[0]!);142+ });143+144+ // T-030f: extreme negative feature values → no NaN or Infinity.145+ // Math.exp(-1000 - max) where max is also -1000 → exp(0) = 1 → stable.146+ it("T-030f: extreme negative feature values → no NaN or Infinity", () => {147+ const extremeNeg: MLFieldFeatures = {} as MLFieldFeatures;148+ for (const key of Object.keys(zeroFeatures()) as (keyof MLFieldFeatures)[]) {149+ (extremeNeg as Record<string, number>)[key] = -1000;150+ }151+152+ const result = predictBatch([extremeNeg]);153+ assertValidPrediction(result[0]!);154+ });155+156+ // T-030g: mixed extreme values (some 1e6, some -1e6) → stable.157+ it("T-030g: mixed extreme positive and negative feature values → stable softmax", () => {158+ const mixed: MLFieldFeatures = {} as MLFieldFeatures;159+ let i = 0;160+ for (const key of Object.keys(zeroFeatures()) as (keyof MLFieldFeatures)[]) {161+ (mixed as Record<string, number>)[key] = i % 2 === 0 ? 1e6 : -1e6;162+ i++;163+ }164+165+ const result = predictBatch([mixed]);166+ assertValidPrediction(result[0]!);167+ });168+169+ // T-030h: output type is always one of the 5 known FieldType values.170+ // Bug this catches: CLASSES[maxIdx] would be undefined if maxIdx is out of171+ // range (shouldn't happen, but the test pins the contract).172+ it("T-030h: all predictions have a valid FieldType (not undefined)", () => {173+ const cases = [174+ zeroFeatures(),175+ passwordFeatures(),176+ { ...zeroFeatures(), type_email: 1, auto_email: 1, name_has_email: 1 },177+ { ...zeroFeatures(), auto_one_time_code: 1, inputmode_numeric: 1 },178+ ];179+180+ const results = predictBatch(cases);181+ for (const r of results) {182+ expect(VALID_TYPES).toContain(r.type);183+ }184+ });185+});src/shared/generate.empty-pool.test.ts+130 −0
@@ -0,0 +1,130 @@1+// T-025: generate.ts empty-pool guard, 128-char boundary, minLength > maxLength2+//3+// Why these tests exist: generatePassword and generateSmartPassword have three4+// edge-case behaviors that are not tested:5+//6+// 1. All character classes forbidden → the pool falls back to lowercase7+// ("abcdefghijklmnopqrstuvwxyz") rather than looping forever or throwing.8+// (line 38: `if (chars.length === 0) chars = filter(...) || "abc..."`)9+//10+// 2. 128-character limit (raised in v1.8.0) — the generator must produce11+// exactly 128 characters when length=128 and must handle the boundary12+// without a performance cliff or off-by-one.13+//14+// 3. minLength > maxLength in a site policy — generate.ts clamps in15+// declaration order: raise to minLength first, then cap to maxLength.16+// When minLength=50 and maxLength=16, the result must be exactly 16 chars.17+//18+// These tests also serve as a regression guard against any future refactor of19+// the rejection-sampling loop: if the loop condition changes, the 128-char20+// case catches a potential performance regression (loop would run for 30+s).21+22+import { describe, expect, it } from "vitest";23+import { generatePassword, generateSmartPassword } from "./generate";24+25+describe("T-025: generatePassword edge cases", () => {26+ // T-025a: all character classes excluded via forbiddenChars → falls back to27+ // lowercase alphabet, no infinite loop, no error.28+ //29+ // Bug this catches: removing the `|| "abcde..."` fallback in the empty-pool30+ // guard would cause an infinite loop (the while loop never fills the length).31+ it("T-025a: all chars forbidden via forbiddenChars → falls back to lowercase, no hang", () => {32+ // Forbid every uppercase, digit, and symbol; force-enable all classes.33+ // Only lowercase survives.34+ const allExceptLower = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";35+ const password = generatePassword({36+ length: 10,37+ includeUppercase: true,38+ includeLowercase: true,39+ includeNumbers: true,40+ includeSymbols: true,41+ forbiddenChars: allExceptLower,42+ });43+44+ expect(typeof password).toBe("string");45+ expect(password.length).toBe(10);46+ // Remaining chars should only be lowercase47+ expect(password).toMatch(/^[a-z]+$/);48+ });49+50+ // T-025b: all character classes disabled (no uppercase, no lowercase, no51+ // numbers, no symbols) — the pool would be empty, so the fallback ensures52+ // the generator produces something usable.53+ it("T-025b: all boolean classes disabled → fallback lowercase used, no hang", () => {54+ const password = generatePassword({55+ length: 8,56+ includeUppercase: false,57+ includeLowercase: false,58+ includeNumbers: false,59+ includeSymbols: false,60+ });61+62+ expect(typeof password).toBe("string");63+ expect(password.length).toBe(8);64+ // Fallback to lowercase: output should be alphabetic65+ expect(password).toMatch(/^[a-z]+$/);66+ });67+68+ // T-025c: 128-character password generated without performance cliff.69+ // v1.8.0 raised the maximum to 128 chars. The rejection-sampling loop70+ // must complete in reasonable time (the test has a 2-second implicit timeout).71+ //72+ // Bug this catches: if the rejection-sampling limit calculation overflows73+ // or produces 0 for large char pools, every CSPRNG value is rejected and74+ // the loop runs indefinitely.75+ it("T-025c: length=128 produces exactly 128 characters without hanging", () => {76+ const password = generatePassword({77+ length: 128,78+ includeUppercase: true,79+ includeLowercase: true,80+ includeNumbers: true,81+ includeSymbols: true,82+ });83+84+ expect(password.length).toBe(128);85+ });86+87+ // T-025d: length=1 (minimum sensible boundary).88+ it("T-025d: length=1 produces exactly 1 character", () => {89+ const password = generatePassword({ length: 1 });90+ expect(password.length).toBe(1);91+ });92+93+ // T-025e: output is CSPRNG-based; calling twice produces different results.94+ // Not a determinism guarantee, but a sanity check that the output is not95+ // a hardcoded/constant string.96+ it("T-025e: two calls produce different passwords (CSPRNG is live)", () => {97+ const a = generatePassword({ length: 32 });98+ const b = generatePassword({ length: 32 });99+ // Collision probability: 1/(pool^32) ≈ 0 for any reasonable pool100+ expect(a).not.toBe(b);101+ });102+});103+104+describe("T-025: generateSmartPassword policy clamping", () => {105+ // T-025f: minLength > maxLength — generateSmartPassword clamps to maxLength.106+ // The evaluation order is: raise to minLength first (→ 50), then cap to107+ // maxLength (→ 16). Site policy maxLength must win.108+ //109+ // Bug this catches: swapping the clamp order so minLength wins over maxLength110+ // would produce a password that violates the site's stated maximum, causing111+ // sign-up failure on strict sites.112+ it("T-025f: minLength > maxLength → maxLength wins (site maximum clamped)", () => {113+ const password = generateSmartPassword({ minLength: 50, maxLength: 16 });114+ expect(password.length).toBeLessThanOrEqual(16);115+ });116+117+ // T-025g: maxLength below the safety floor of 8 → floor applies.118+ // generateSmartPassword has `if (length < 8) length = 8` to prevent119+ // trivially weak passwords being auto-generated for restrictive sites.120+ it("T-025g: maxLength=4 → safety floor raises output to at least 8 chars", () => {121+ const password = generateSmartPassword({ maxLength: 4 });122+ expect(password.length).toBeGreaterThanOrEqual(8);123+ });124+125+ // T-025h: forbidsSymbols=true → no symbol characters in output.126+ it("T-025h: forbidsSymbols=true → no !@#$%^&* in password", () => {127+ const password = generateSmartPassword({ forbidsSymbols: true, minLength: 20 });128+ expect(password).not.toMatch(/[!@#$%^&*]/);129+ });130+});