fix(auth): stop drawing a reset form the link can no longer fill (E9.5 S4)
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.
This commit is contained in:
@@ -51,7 +51,7 @@ async function mountCard(url: string, apiOverrides: Record<string, unknown> = {}
|
||||
await act(async () => {
|
||||
render(<Welcome verify={noVerify} />);
|
||||
});
|
||||
return { HttpError };
|
||||
return { HttpError, Welcome };
|
||||
}
|
||||
|
||||
/** Fill the sign-in form and submit it. */
|
||||
@@ -148,6 +148,96 @@ describe("a refused sign-in becomes a named state", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// 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");
|
||||
|
||||
Reference in New Issue
Block a user