The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The honest states of the sign-in card. 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, beforeAll, 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: () => {},
|
|
};
|
|
|
|
// Every case re-imports the whole Welcome tree after a module reset. The FIRST import also transforms
|
|
// it, which under the full suite's parallel load ran past the 5 s test timeout (a gate flake).
|
|
// Transforms survive resetModules, so warming them once here leaves each case only the evaluation.
|
|
beforeAll(async () => {
|
|
await import("./Welcome");
|
|
}, 60_000);
|
|
|
|
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");
|
|
|
|
// The two gates are 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();
|
|
});
|
|
});
|