// @vitest-environment jsdom // // The honest states of the sign-in card (E9.5 S4). Two things are under test and they share a root: // a screen that does not name the address it is talking about hides an alias mismatch. On 2026-09-03 // a registration as `np.temu@gmail.com` met a Google claim of `nptemu@gmail.com` and every screen in // the funnel stayed silent about it. // // entryUrl snapshots window.location at IMPORT time, so the access-notice cases reset the module // registry and set the URL before importing — that is the behaviour under test, not a workaround. import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import type { VerifyEntry } from "../lib/useVerifyEntry"; const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} }; afterEach(() => { cleanup(); vi.resetModules(); vi.restoreAllMocks(); window.history.replaceState(null, "", "/"); }); /** Mount the real card at a given entry URL, with i18n as a key-passthrough and /auth/* stubbed. */ async function mountCard(url: string, apiOverrides: Record = {}) { window.history.replaceState(null, "", url); vi.resetModules(); vi.doMock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string, opts?: Record) => opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key, i18n: { language: "en" }, }), }; }); vi.doMock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, api: { ...actual.api, authConfig: () => Promise.resolve({ google_enabled: false }), ...apiOverrides, }, }; }); const { HttpError } = await import("../lib/api"); const { default: Welcome } = await import("./Welcome"); await act(async () => { render(); }); return { HttpError, Welcome }; } /** Fill the sign-in form and submit it. */ async function signIn(email = "someone@example.com") { fireEvent.change(screen.getByPlaceholderText("welcome.auth.email"), { target: { value: email } }); fireEvent.change(screen.getByPlaceholderText("welcome.auth.password"), { target: { value: "correct horse battery" }, }); await act(async () => { fireEvent.click(screen.getByText("welcome.auth.signinButton")); }); } /** A password-login refusal carrying the server's structured detail. */ function refusal( HttpError: typeof import("../lib/api").HttpError, detail: Record, ) { return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail })); } describe("a refused sign-in becomes a named state", () => { it("names the address the account is on, not the one that was typed", async () => { const passwordLogin = vi.fn(); const { HttpError } = await mountCard("/", { passwordLogin }); passwordLogin.mockImplementation( refusal(HttpError, { reason: "pending_approval", email: "np.temu@gmail.com", email_verified: true, approved: false, }), ); await signIn("nptemu@gmail.com"); expect(screen.getByText("welcome.auth.blocked.pending_approval.title")).toBeTruthy(); expect(screen.getByText("np.temu@gmail.com")).toBeTruthy(); // The password was already proven correct, so the form is not the next step and must be gone — // leaving it up would only invite a retry that cannot succeed. expect(screen.queryByPlaceholderText("welcome.auth.password")).toBeNull(); }); it("reports each gate from its own flag, so an approved-but-unverified account is told the truth", async () => { const passwordLogin = vi.fn(); const { HttpError } = await mountCard("/", { passwordLogin }); passwordLogin.mockImplementation( refusal(HttpError, { reason: "email_unverified", email: "approved.first@example.com", email_verified: false, approved: true, }), ); await signIn("approved.first@example.com"); // E9.5 S1 made the two gates order-independent; a screen that assumed a sequence would send this // user off to wait for an admin who has already decided. expect(screen.getByText("welcome.auth.blocked.gate.emailPending")).toBeTruthy(); expect(screen.getByText("welcome.auth.blocked.gate.approvalDone")).toBeTruthy(); expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy(); }); it("shows no checklist for a suspension, which is not a gate that opens on its own", async () => { const passwordLogin = vi.fn(); const { HttpError } = await mountCard("/", { passwordLogin }); passwordLogin.mockImplementation( refusal(HttpError, { reason: "suspended", email: "banned@example.com", operator: "ops@example.com", }), ); await signIn("banned@example.com"); expect(screen.getByText("welcome.auth.blocked.suspended.title")).toBeTruthy(); expect(screen.getByText("welcome.auth.blocked.operator:ops@example.com")).toBeTruthy(); expect(screen.queryByText("welcome.auth.blocked.gate.emailDone")).toBeNull(); }); it("leaves a wrong password as a line under the form, where the form IS the next step", async () => { const passwordLogin = vi.fn(); const { HttpError } = await mountCard("/", { passwordLogin }); passwordLogin.mockImplementation(() => Promise.reject(new HttpError(401, "localized invalid", { code: "login", reason: "invalid" })), ); await signIn(); expect(screen.getByText("localized invalid")).toBeTruthy(); expect(screen.getByPlaceholderText("welcome.auth.password")).toBeTruthy(); }); }); // Reported 2026-09-05: delete an account, then open its reset link. The card drew the full "set a new // password" form purely because a `#reset=` fragment was present, and only told the user after they // had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and // whoever got two mails clicks the older one. describe("an emailed reset link that is already finished", () => { it("refuses to draw a form that cannot succeed", async () => { await mountCard("/#reset=dead-token", { checkPasswordReset: () => Promise.resolve({ ok: false }), }); expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy(); expect(screen.getByText("welcome.auth.deadLink.cta")).toBeTruthy(); expect(screen.queryByPlaceholderText("welcome.auth.newPassword")).toBeNull(); }); it("draws the form when the link is still good", async () => { await mountCard("/#reset=live-token", { checkPasswordReset: () => Promise.resolve({ ok: true }), }); expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy(); expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull(); }); it("takes the form down if the link dies between the check and the submit", async () => { const confirmPasswordReset = vi.fn(); const { HttpError } = await mountCard("/#reset=racing-token", { checkPasswordReset: () => Promise.resolve({ ok: true }), confirmPasswordReset, }); confirmPasswordReset.mockImplementation(() => Promise.reject(new HttpError(400, "ignored", { code: "reset", reason: "invalid" })), ); fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), { target: { value: "brand new password" }, }); await act(async () => { fireEvent.click(screen.getByText("welcome.auth.resetButton")); }); expect(screen.getByText("welcome.auth.deadLink.title")).toBeTruthy(); }); it("keeps the form up for a too-short password, which costs the link nothing", async () => { const confirmPasswordReset = vi.fn(); const { HttpError } = await mountCard("/#reset=live-token", { checkPasswordReset: () => Promise.resolve({ ok: true }), confirmPasswordReset, }); confirmPasswordReset.mockImplementation(() => Promise.reject( new HttpError(400, "too short", { code: "reset", reason: "weak_password", min: 10 }), ), ); fireEvent.change(screen.getByPlaceholderText("welcome.auth.newPassword"), { target: { value: "0123456789ab" }, }); await act(async () => { fireEvent.click(screen.getByText("welcome.auth.resetButton")); }); expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy(); expect(screen.queryByText("welcome.auth.deadLink.title")).toBeNull(); }); it("probes a token once, however many times the card mounts", async () => { const checkPasswordReset = vi.fn().mockResolvedValue({ ok: true }); const { Welcome } = await mountCard("/#reset=counted-token", { checkPasswordReset }); await act(async () => { render(); }); // StrictMode double-invokes effects and the shell remounts this card as the session probe // settles; four probes per opened link would exhaust the endpoint's per-IP budget in two reloads // and silently drop back to drawing the form unchecked. expect(checkPasswordReset).toHaveBeenCalledTimes(1); }); it("shows the form when the check itself fails, so a broken probe locks nobody out", async () => { await mountCard("/#reset=live-token", { checkPasswordReset: () => Promise.reject(new Error("network")), }); expect(screen.getByPlaceholderText("welcome.auth.newPassword")).toBeTruthy(); }); }); describe("the access-request notice from a denied Google sign-in", () => { it("names the address the request was recorded for", async () => { await mountCard("/#access=requested&email=nptemu%40gmail.com"); expect(screen.getByText("welcome.auth.accessRequestedFor")).toBeTruthy(); expect(screen.getByText("nptemu@gmail.com")).toBeTruthy(); expect(screen.queryByText("welcome.auth.accessLinked")).toBeNull(); }); it("explains the alias when the request is already on file under another spelling", async () => { await mountCard( "/#access=requested&email=nptemu%40gmail.com&linked=np.temu%2Bsiftlode%40gmail.com", ); expect(screen.getByText("welcome.auth.accessLinked")).toBeTruthy(); // %2B, not a bare `+`: URLSearchParams reads the latter as a space and would mangle exactly the // address family this epic exists to protect. expect(screen.getByText("np.temu+siftlode@gmail.com")).toBeTruthy(); }); it("falls back to the unnamed notice when the server sent no address", async () => { await mountCard("/#access=requested"); expect(screen.getByText("welcome.auth.accessRequested")).toBeTruthy(); expect(screen.queryByText("welcome.auth.accessRequestedFor")).toBeNull(); }); });