content.save.test.ts

6.4 KB · raw

1// @vitest-environment jsdom2//3// Test File 9 — save.ts save-on-submit detection (T-SV01–T-SV10).4//5// setupSaveDetection installs a document submit listener that scrapes the6// submitted username/password, looks up a matching vault entry7// (AUTOFILL_QUERY → GET_ENTRY_FOR_FILL), and shows a save/update banner unless8// the credentials already match. The banner itself is a CLOSED shadow root, and9// its Save/Update buttons (which send ADD_ENTRY/UPDATE_ENTRY) are unreachable10// from outside — so those acceptance flows are routed to the e2e. What we pin11// here is the decision: does the banner mount, and is the lookup correct?1213import "./_setup";14import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";15import { setupSaveDetection } from "./save";16import { resetChromeMock, setMessageResponder, sentMessages, makeInput } from "./_setup";1718// Mounted save/update banner host (top-right, max z-index, closed shadow).19function bannerHost(): HTMLElement | null {20  return document.body.querySelector<HTMLElement>('div[style*="2147483647"]');21}2223// Build and submit a form with the given fields; returns after the async24// save-detection chain settles.25async function submitForm(fields: { user?: string; pass?: string; type?: string }): Promise<void> {26  const form = document.createElement("form");27  document.body.appendChild(form);28  if (fields.user !== undefined) {29    const u = makeInput({ type: "text", name: "username", autocomplete: "username" }, form);30    u.value = fields.user;31  }32  if (fields.pass !== undefined) {33    const p = makeInput({ type: fields.type ?? "password", name: "password" }, form);34    p.value = fields.pass;35  }36  form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));37  await new Promise((r) => setTimeout(r, 30)); // let the AUTOFILL_QUERY/GET_ENTRY roundtrip settle38}3940beforeAll(() => {41  setupSaveDetection(); // one document submit listener for the whole file42});4344beforeEach(() => {45  document.body.innerHTML = "";46  resetChromeMock();47});48afterEach(() => {49  document.body.innerHTML = "";50});5152describe("T-SV01, T-SV03: banner mounts for new and changed credentials", () => {53  // T-SV01: submitting credentials with no matching vault entry → save banner.54  it("T-SV01: new credentials → save banner mounts", async () => {55    setMessageResponder((msg) => (msg.type === "AUTOFILL_QUERY" ? { ok: true, data: [] } : { ok: true, data: null }));56    await submitForm({ user: "alice", pass: "s3cret" });57    expect(bannerHost()).not.toBeNull();58  });5960  // T-SV03: submitting a changed password for an existing entry → update banner.61  it("T-SV03: changed password for an existing entry → banner mounts (update)", async () => {62    setMessageResponder((msg) => {63      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };64      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old-password" } };65      return { ok: true, data: null };66    });67    await submitForm({ user: "alice", pass: "new-password" });68    expect(bannerHost()).not.toBeNull();69  });70});7172describe("T-SV02, T-SV04, T-SV05: no banner cases", () => {73  // T-SV02: the submitted password already matches the saved entry → no banner.74  it("T-SV02: credentials already saved (password matches) → no banner", async () => {75    setMessageResponder((msg) => {76      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };77      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "s3cret" } };78      return { ok: true, data: null };79    });80    await submitForm({ user: "alice", pass: "s3cret" });81    expect(bannerHost()).toBeNull();82  });8384  // T-SV04: a search form has no password field — save detection ignores it and85  // never even runs the vault lookup.86  it("T-SV04: search form (no password) → no banner, no vault lookup", async () => {87    setMessageResponder(() => ({ ok: true, data: [] }));88    const form = document.createElement("form");89    document.body.appendChild(form);90    const q = makeInput({ type: "search", name: "q" }, form);91    q.value = "kittens";92    form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));93    await new Promise((r) => setTimeout(r, 30));94    expect(bannerHost()).toBeNull();95    expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false);96  });9798  // T-SV05: an empty password short-circuits before any lookup.99  it("T-SV05: empty password → no banner, no vault lookup", async () => {100    setMessageResponder(() => ({ ok: true, data: [] }));101    await submitForm({ user: "alice", pass: "" });102    expect(bannerHost()).toBeNull();103    expect(sentMessages.some((m) => m.type === "AUTOFILL_QUERY")).toBe(false);104  });105});106107describe("T-SV10: domain-matched lookup (MV-1 surface)", () => {108  // T-SV10: the lookup queries AUTOFILL_QUERY with the page domain, then fetches109  // the full entry by the returned id via GET_ENTRY_FOR_FILL.110  it("T-SV10: sends AUTOFILL_QUERY{domain} then GET_ENTRY_FOR_FILL{id}", async () => {111    setMessageResponder((msg) => {112      if (msg.type === "AUTOFILL_QUERY") return { ok: true, data: [{ id: "e1" }] };113      if (msg.type === "GET_ENTRY_FOR_FILL") return { ok: true, data: { id: "e1", title: "Acme", password: "old" } };114      return { ok: true, data: null };115    });116    await submitForm({ user: "alice", pass: "new" });117118    const query = sentMessages.find((m) => m.type === "AUTOFILL_QUERY");119    expect(query?.payload?.domain).toBeTruthy();120    const fetch = sentMessages.find((m) => m.type === "GET_ENTRY_FOR_FILL");121    expect(fetch?.payload?.id).toBe("e1");122  });123});124125// ── Closed-shadow / lifecycle boundaries (T-SV06–T-SV09) ─────────────────────126describe("T-SV06–T-SV09: documented boundaries", () => {127  // The Save/Dismiss buttons and the homograph warning render inside the128  // banner's CLOSED shadow root; the accept/dismiss flows (which send129  // ADD_ENTRY/UPDATE_ENTRY), the popup-lifecycle suppression, and the homograph130  // warning copy are all verified through the Playwright e2e. See131  // INTEGRATION-CONTRACT.md.132  it.todo("T-SV06: user accepts the banner → ADD_ENTRY sent (closed shadow → e2e)");133  it.todo("T-SV07: user dismisses the banner → nothing saved (closed shadow → e2e)");134  it.todo("T-SV08: banner does not reappear after popup lifecycle break (e2e)");135  it.todo("T-SV09: homograph warning shown for non-ASCII domain (closed shadow → e2e)");136});