DX-17, three one-liners the epic's canonical sweep did not reach:
`routes/setup.py::setup_admin` still resolved the wizard's admin by
literal `User.email ==`. The wizard can be re-run, and E9.5's premise is
that two spellings of one Gmail mailbox are one person — so a second run
under the other spelling would have created exactly the duplicate admin
row the epic exists to prevent.
`routes/admin.py::_decide` hand-rolled `db.get` + 404 where the file's
newer endpoints use `get_or_404`. There were TWO such sites, not the one
the review named; both are converted, per the house rule that a
principle gets fixed everywhere in one sweep.
`useVerifyEntry`'s `hashchange` listener recognised `#verify=` but not
`#reset=`, so pasting a reset link into a tab already running the app
was silently ignored — the same silence that made the verify half a bug.
It now notices, and says out loud that reset links need a signed-out
window; the fragment is still deliberately left in the address bar
(review round 4), so signing out from that tab recovers it.
DX-16: `.catch(() => setOutcome("invalid"))` could not tell a spent
token from a 502 mid-deploy or a dropped connection, and by then the
fragment is cleared — so a network blip was reported as "that link is no
longer valid", pushing the user to request a fresh one on the strength
of a false statement. Only a 400 carrying {code:"verify"} is the
server's verdict now; everything else gets its own outcome, with no
resend CTA, because the token is untouched and retrying it works. The
rule was already written one file away for the reset-link probe: "a
network blip must never be cached as 'this link is dead'".
Two existing tests asserted the old conflation by rejecting with a bare
Error("400"); they now reject with the real HttpError. The errors are
built inside the rejection, not hoisted: mountAt calls vi.resetModules(),
so an HttpError imported earlier is a different class identity and
`instanceof` would quietly miss.
Gate: vitest 671 -> 676, pytest 924 -> 925.
273 lines
11 KiB
TypeScript
273 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,
|
|
resetPending: false,
|
|
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();
|
|
});
|
|
});
|