fix(auth): consume a verification token that arrives without a page load (E9.5 S2)

Found in UAT. Navigating a tab that already runs the app to a URL differing only in
the fragment is a same-document navigation: nothing reloads, so the import-time entry
snapshot never sees the token. It sat unconsumed in the address bar and the previous
outcome stayed on screen -- the same silence this sprint set out to remove, wearing a
different hat. Pasting the emailed link into a running tab does exactly this, and our
verification mails were plain text until 0.78.8, which makes pasting the normal way to
use one.

A hashchange listener now picks up the new token, the guard tracks consumed tokens
rather than a single fired flag (still exactly one POST per token), and a fresh link
clears the standing outcome instead of leaving the old banner up.
This commit is contained in:
2026-09-03 23:57:18 +02:00
parent c134b514c1
commit e5997ca567
2 changed files with 76 additions and 6 deletions
+49
View File
@@ -85,3 +85,52 @@ describe("confirming an emailed verification link", () => {
expect(window.location.hash).toBe("");
});
});
// The link does not always arrive as a page LOAD. Found in UAT on 2026-09-03: navigating a tab that
// already runs the app to a URL differing only in the fragment is a same-document navigation, so
// nothing reloads, the import-time snapshot never sees the token, and it sat unconsumed in the
// address bar — the same silence the sprint set out to remove, wearing a different hat.
describe("a verification link that arrives as a fragment-only navigation", () => {
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
const outcome = await mountAt("/", verifyEmail);
expect(verifyEmail).not.toHaveBeenCalled();
await act(async () => {
window.history.replaceState(null, "", "/#verify=pasted-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
expect(outcome()).toBe("ok:pasted@example.com");
expect(window.location.hash).toBe("");
});
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
const verifyEmail = vi
.fn()
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
.mockRejectedValueOnce(new Error("400"));
const outcome = await mountAt("/#verify=first-token", verifyEmail);
expect(outcome()).toBe("ok:first@example.com");
await act(async () => {
window.history.replaceState(null, "", "/#verify=spent-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(outcome()).toBe("invalid:-");
});
it("posts a given token once even if the same fragment arrives again", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
await mountAt("/#verify=same-token", verifyEmail);
await act(async () => {
window.history.replaceState(null, "", "/#verify=same-token");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(verifyEmail).toHaveBeenCalledTimes(1);
});
});