fix(auth): close the three leftovers E9.5 logged but kept out of its diff
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.
This commit is contained in:
@@ -14,15 +14,33 @@ import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
// wrong-account case is why the address is named.
|
||||
export default function VerifyBanner({ entry }: { entry: VerifyEntry }) {
|
||||
const { t } = useTranslation();
|
||||
// A reset link opened here can only be ACTED on signed out, so say that rather than nothing —
|
||||
// silence is precisely what made the verify half a bug. The token stays in the address bar, so
|
||||
// signing out from this same tab picks it up.
|
||||
if (!entry.outcome && entry.resetPending) {
|
||||
return (
|
||||
<Banner
|
||||
tone="warning"
|
||||
icon={AlertTriangle}
|
||||
role="status"
|
||||
onDismiss={entry.dismiss}
|
||||
dismissTitle={t("header.banner.dismiss")}
|
||||
>
|
||||
{t("header.verify.resetSignedOut")}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (!entry.outcome) return null;
|
||||
|
||||
const ok = entry.outcome !== "invalid";
|
||||
const ok = entry.outcome === "ok" || entry.outcome === "ok_reset";
|
||||
const text =
|
||||
entry.outcome === "invalid"
|
||||
? t("header.verify.invalid")
|
||||
: entry.email
|
||||
? t("header.verify.confirmedNamed", { email: entry.email })
|
||||
: t("header.verify.confirmed");
|
||||
: entry.outcome === "unreachable"
|
||||
? t("header.verify.unreachable")
|
||||
: entry.email
|
||||
? t("header.verify.confirmedNamed", { email: entry.email })
|
||||
: t("header.verify.confirmed");
|
||||
|
||||
return (
|
||||
<Banner
|
||||
|
||||
@@ -555,6 +555,12 @@ function AuthCard({ verify }: { verify: VerifyEntry }) {
|
||||
</button>
|
||||
</Banner>
|
||||
)}
|
||||
{/* No resend CTA here, unlike the invalid banner below: the token was NOT spent — we simply
|
||||
could not reach the server — so pushing the user to request a fresh link would act on a
|
||||
statement we know to be false (DX-16). Retrying the same link is the correct next move. */}
|
||||
{verifyOutcome === "unreachable" && (
|
||||
<Banner tone="warn">{t("welcome.auth.verifyUnreachable")}</Banner>
|
||||
)}
|
||||
{verifyOutcome === "invalid" && (
|
||||
<Banner tone="warn">
|
||||
{t("welcome.auth.verifyInvalid")}{" "}
|
||||
|
||||
@@ -11,7 +11,12 @@ 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: () => {} };
|
||||
const noVerify: VerifyEntry = {
|
||||
outcome: null,
|
||||
email: null,
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
||||
@@ -32,7 +32,12 @@ import type { VerifyEntry } from "../lib/useVerifyEntry";
|
||||
|
||||
// App confirms the emailed token and hands the outcome down; these tests are about the card's
|
||||
// sub-views, so there is nothing to report.
|
||||
const noVerify: VerifyEntry = { outcome: null, email: null, dismiss: () => {} };
|
||||
const noVerify: VerifyEntry = {
|
||||
outcome: null,
|
||||
email: null,
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
};
|
||||
|
||||
const back = () =>
|
||||
act(() => {
|
||||
@@ -91,6 +96,7 @@ describe("the verification outcome handed down from App", () => {
|
||||
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
|
||||
outcome,
|
||||
email: "someone@example.com",
|
||||
resetPending: false,
|
||||
dismiss: () => {},
|
||||
});
|
||||
|
||||
@@ -112,6 +118,15 @@ describe("the verification outcome handed down from App", () => {
|
||||
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
||||
});
|
||||
|
||||
// DX-16: a transport failure is not a verdict on the token, so it must NOT offer a fresh link —
|
||||
// that would act on a statement we know to be false. Retrying the same link is the way out.
|
||||
it("does not offer a new link when the server was merely unreachable", async () => {
|
||||
render(<Welcome verify={withOutcome("unreachable")} />);
|
||||
expect(await screen.findByText("welcome.auth.verifyUnreachable")).toBeTruthy();
|
||||
expect(screen.queryByText("welcome.auth.resendCta")).toBeNull();
|
||||
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows no banner when there was no token to confirm", async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
await screen.findByText("welcome.auth.signinTitle");
|
||||
|
||||
Reference in New Issue
Block a user