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:
@@ -30,7 +30,12 @@ async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
||||
|
||||
function Probe() {
|
||||
const entry = useVerifyEntry();
|
||||
return <div data-testid="outcome">{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}</div>;
|
||||
return (
|
||||
<>
|
||||
<div data-testid="outcome">{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}</div>
|
||||
<div data-testid="reset">{entry.resetPending ? "reset-pending" : "-"}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
await act(async () => {
|
||||
render(<Probe />);
|
||||
@@ -67,14 +72,51 @@ describe("confirming an emailed verification link", () => {
|
||||
expect(outcome()).toBe("ok_reset:revoked@example.com");
|
||||
});
|
||||
|
||||
it("reports invalid when the link is expired or already used", async () => {
|
||||
const verifyEmail = vi.fn().mockRejectedValue(new Error("400"));
|
||||
// Errors are built INSIDE the rejection, not hoisted above the test: mountAt calls
|
||||
// vi.resetModules(), so an HttpError imported earlier would be a different class identity than the
|
||||
// one the hook imports afterwards, and `instanceof` would quietly miss.
|
||||
const rejectWith = (make: (E: typeof import("./api/core").HttpError) => unknown) =>
|
||||
vi.fn().mockImplementation(async () => {
|
||||
const { HttpError } = await import("./api/core");
|
||||
throw make(HttpError);
|
||||
});
|
||||
|
||||
it("reports invalid when the SERVER says the link is expired or already used", async () => {
|
||||
const verifyEmail = rejectWith((E) => new E(400, "verify", { code: "verify" }));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-dead", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
|
||||
// DX-16: the fragment is already cleared by the time this resolves, so reporting a transport
|
||||
// failure as "that link is no longer valid" pushes the user into requesting a fresh link on the
|
||||
// strength of a statement that is false — the token is untouched and retrying it works.
|
||||
it("does not call the link dead on a gateway failure mid-deploy", async () => {
|
||||
const verifyEmail = rejectWith((E) => new E(502, "Bad Gateway"));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-live", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("unreachable:-");
|
||||
});
|
||||
|
||||
it("does not call the link dead when the connection drops with no HTTP status at all", async () => {
|
||||
const verifyEmail = vi.fn().mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-offline", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("unreachable:-");
|
||||
});
|
||||
|
||||
// A 400 that is not the verify refusal is still not a verdict on the token.
|
||||
it("does not call the link dead on a 400 that is not the verify refusal", async () => {
|
||||
const verifyEmail = rejectWith((E) => new E(400, "nope", { code: "other" }));
|
||||
|
||||
const outcome = await mountAt("/#verify=tok-other", verifyEmail);
|
||||
|
||||
expect(outcome()).toBe("unreachable:-");
|
||||
});
|
||||
|
||||
it("clears the one-time query flags it owns even with nothing to confirm", async () => {
|
||||
const verifyEmail = vi.fn();
|
||||
|
||||
@@ -97,6 +139,22 @@ describe("confirming an emailed verification link", () => {
|
||||
|
||||
expect(window.location.hash).toBe("#reset=unspent");
|
||||
expect(window.location.search).toBe(""); // the flag it does own still goes
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
});
|
||||
|
||||
// DX-17: the verify half got a hashchange listener in E9.5 S2, the reset half did not — so pasting
|
||||
// a reset link into a tab already running the app was silently ignored. Our mails were plain text
|
||||
// until 0.78.8, which makes pasting the normal way to use one.
|
||||
it("notices a reset link pasted into a tab that is already running", async () => {
|
||||
await mountAt("/", vi.fn());
|
||||
expect(screen.getByTestId("reset").textContent).toBe("-");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(window.history.state, "", "/#reset=pasted");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
|
||||
});
|
||||
|
||||
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
|
||||
@@ -145,7 +203,12 @@ describe("a verification link that arrives as a fragment-only navigation", () =>
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
|
||||
.mockRejectedValueOnce(new Error("400"));
|
||||
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
|
||||
// token is spent, and this case is about the SECOND outcome replacing the first.
|
||||
.mockImplementationOnce(async () => {
|
||||
const { HttpError } = await import("./api/core");
|
||||
throw new HttpError(400, "verify", { code: "verify" });
|
||||
});
|
||||
const outcome = await mountAt("/#verify=first-token", verifyEmail);
|
||||
expect(outcome()).toBe("ok:first@example.com");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user