fix(auth): confirm an emailed verification link wherever it is opened (E9.5 S2)

The token was only ever consumed inside Welcome.tsx, which App renders ONLY when
there is no session. Opening the emailed link in a browser that already held a
Siftlode session posted nothing, the fragment was then stripped, and the user got
no signal at all -- the link simply did nothing. Incognito worked, which is why it
survived. Confirmation now happens in App, above that branch: signed out the outcome
still drives the auth card's banners, signed in it drives a top-of-app bar.

The entry URL is snapshotted once at import time (lib/entryUrl) instead of per
component, so no future URL writer can strip a token before whoever needs it has
read it -- that ordering race is what broke this in the first place. POST /auth/verify
now names the address it confirmed, which matters when the browser holds a session
for a different account; the same reasoning as password_reset_required already
covers the disclosure.

Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both
senders emit the fragment form, and the last query-token link expired 24h after the
0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and
the auth-card banners get role=status. The verifyOkReset copy no longer blames
admin approval, which E9.5 S1 made false.
This commit is contained in:
2026-09-03 23:45:31 +02:00
parent 243869401a
commit c134b514c1
15 changed files with 337 additions and 73 deletions
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
//
// The emailed verification link, confirmed wherever it is opened. The regression this pins: the token
// used to be consumed inside Welcome.tsx, which App renders ONLY when there is no session — so opening
// the link in a browser that already held a Siftlode session posted nothing, the fragment was then
// stripped, and the user got no signal at all. Incognito worked, which is why it survived.
//
// entryUrl snapshots window.location at IMPORT time, so each case resets the module registry and sets
// the URL before importing — that is the behaviour under test, not a workaround.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, act } from "@testing-library/react";
afterEach(() => {
cleanup();
vi.resetModules();
vi.restoreAllMocks();
window.history.replaceState(null, "", "/");
});
/** Mount the hook at a given entry URL with a stubbed /auth/verify, and hand back what it reported. */
async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
window.history.replaceState(null, "", url);
vi.resetModules();
vi.doMock("./api", async (importOriginal) => {
const actual = await importOriginal<typeof import("./api")>();
return { ...actual, api: { ...actual.api, verifyEmail } };
});
const { useVerifyEntry } = await import("./useVerifyEntry");
function Probe() {
const entry = useVerifyEntry();
return <div data-testid="outcome">{`${entry.outcome ?? "none"}:${entry.email ?? "-"}`}</div>;
}
await act(async () => {
render(<Probe />);
});
return () => screen.getByTestId("outcome").textContent;
}
describe("confirming an emailed verification link", () => {
it("posts the fragment token and reports the confirmed address", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
const outcome = await mountAt("/#verify=tok-123", verifyEmail);
expect(verifyEmail).toHaveBeenCalledWith("tok-123");
expect(outcome()).toBe("ok:someone@example.com");
});
it("posts exactly once, so a re-render cannot spend the single-use token twice", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "once@example.com" });
await mountAt("/#verify=tok-once", verifyEmail);
// A second POST would 400 on the already-consumed token and flip a success to "invalid".
expect(verifyEmail).toHaveBeenCalledTimes(1);
});
it("reports ok_reset when the account's password had been revoked", async () => {
const verifyEmail = vi
.fn()
.mockResolvedValue({ ok: true, password_reset_required: true, email: "revoked@example.com" });
const outcome = await mountAt("/#verify=tok-reset", verifyEmail);
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"));
const outcome = await mountAt("/#verify=tok-dead", verifyEmail);
expect(outcome()).toBe("invalid:-");
});
it("clears the address bar even with nothing to confirm, so a spent token leaves no trace", async () => {
const verifyEmail = vi.fn();
const outcome = await mountAt("/?access=requested#reset=spent", verifyEmail);
expect(verifyEmail).not.toHaveBeenCalled();
expect(outcome()).toBe("none:-");
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("");
});
});