Reported from UAT: delete an account, then open its password-reset link. The card drew the full "set
a new password" form — because a `#reset=` fragment was present, and nothing more — and only after
the user had typed a password did the server answer 400. Measured first: no security hole. A user's
tokens cascade away with the row, so nothing can be set on a deleted or a re-created account. What
was left is a dead end of exactly the kind this epic exists to remove, and it is not rare: a reset
link lives one hour, mail is slow or lands in spam, and whoever got two mails clicks the older one.
password-reset/check answers whether a link is still usable WITHOUT spending it, so asking can never
cost the user their one real attempt — the property the tests pin hardest. It gets its own limiter,
because opening a link must not eat the budget for requesting one, and a throttled caller is told
"ok", never "dead": this check may only ever save someone a wasted form, so when it cannot answer it
degrades to the old behaviour instead of declaring a live link finished. The confirm endpoint stays
the authority, and its failure raises the same named screen, since the token can die between the two
calls or the probe itself may have failed.
A too-short password is deliberately NOT that screen. The length check runs before the token is
spent, so a typo costs the link nothing, and the card tells the two 400s apart by `reason` so only
one of them takes the form down.
Both email-link confirmations (verify, password-reset/confirm) now answer in the {code, reason}
shape S4 introduced. They were the last refusals a user meets by simply clicking an old link while
still being answered in hardcoded English under a bilingual UI. The seven remaining bare strings in
this module (register, request-access, the demo guards, set-password) sit behind client-side
validation the UI does first; they are logged as a follow-up rather than swept in silently here.
One bug found by the UAT itself, in the new code: the check went out FOUR times per opened link —
StrictMode double-invokes effects and the shell remounts the card as the session probe settles.
Harmless per request, but against a per-IP limit it would exhaust the budget in two reloads and then
silently fall back to drawing the form unchecked: nothing errors, the feature just stops working.
Deduped in a module-level map holding the in-flight promise (a ref dies with the remount that causes
it); a failed probe drops itself so a blip is never cached as "dead". The test renders the card
twice, because with one render the guard would rot undetected.
UAT on localdev: the reported scenario end to end (delete the account, open the link) shows the named
screen and no form; a live link still opens the form and completes; the CTA lands on the request-a-
link form; one probe per opened link. Throwaway accounts removed.
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
// @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<string, unknown> = {}) {
|
|
window.history.replaceState(null, "", url);
|
|
vi.resetModules();
|
|
vi.doMock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
t: (key: string, opts?: Record<string, unknown>) =>
|
|
opts && typeof opts.contact === "string" ? `${key}:${opts.contact}` : key,
|
|
i18n: { language: "en" },
|
|
}),
|
|
};
|
|
});
|
|
vi.doMock("../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
|
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(<Welcome verify={noVerify} />);
|
|
});
|
|
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<string, unknown>,
|
|
) {
|
|
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(<Welcome verify={noVerify} />);
|
|
});
|
|
|
|
// 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();
|
|
});
|
|
});
|