Files
siftlode/frontend/src/lib/verifyEntry.dom.test.tsx
T
peter 9f28f46839 fix(e12): unbreak the gate, and stop printing a command that can strand the restore
Round five. 47 -> 34 -> 15 -> 7 -> 6 raw findings, and this round's sharpest was the
loop's own blind spot: **the branch did not pass the gate.** `prettier --check` is one of
its eight lanes and I had been running the other seven, so an unformatted `onError` from
round one sat on the branch for four rounds — `siftlode publish` would have refused it,
with no --skip flag to get past.

**The restart hint could still strand the restored data.** Round four said the scripts no
longer print a command they cannot resolve; they did. The COMPOSE_FILE fallback only fires
when `docker-compose.yml` is ABSENT, so in a source checkout it never fires and the hint
expanded to a bare, pasteable `docker compose up -d` — which reads the other file, whose
volumes are `pgdata`/`downloads` rather than `siftlode_pgdata`/`siftlode_downloads`, and
recreates the database on an empty one. When the file is unresolved the scripts now print
`-f <the compose file you started with>`: not runnable, which is the point.

**A comment naming the wrong owner.** `formats.py` still said a zero budget is "a refusal
for enqueue to make" — the premise round one disproved, three files away from the two
docstrings that now say the opposite. Anyone acting on it would delete the worker's refusal
and re-open the original inversion, so it now says where the refusal actually lives.

Also: the reset-flag rule was rewritten in three consecutive rounds and pinned by nothing —
two tests now cover a fragment losing its token, both by a pasted verify link and by
navigating away entirely; `backup.ps1` deletes a partially-copied dump before throwing, the
guard round four gave the docs but not the script it mirrors; and one comment that read as
if the asset were untouched by a line four below it.

Gate green, all eight lanes: ruff, ruff format, 3x tsc, eslint, prettier, knip,
vitest 687, pytest 950.
2026-09-05 04:41:21 +02:00

297 lines
13 KiB
TypeScript

// @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>) {
// Carries whatever state the case set up: history.state is part of what the hook must not destroy.
window.history.replaceState(window.history.state, "", 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>
<div data-testid="reset">{entry.resetPending ? "reset-pending" : "-"}</div>
<button data-testid="dismiss" onClick={entry.dismiss} />
</>
);
}
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");
});
// 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();
const outcome = await mountAt("/?access=requested", verifyEmail);
expect(verifyEmail).not.toHaveBeenCalled();
expect(outcome()).toBe("none:-");
expect(window.location.search).toBe("");
});
// Only the signed-out auth card consumes a `#reset=` token, so in a browser that already holds a
// session nobody reads it — and erasing it turned the link into a dead end with no explanation:
// the page loads, nothing happens, and clicking it again does the same, because the token is
// untouched and the session is still there. Leaving it keeps the recovery: signing out reloads
// with the fragment intact and the reset form is waiting.
it("leaves an unspent reset link in the address bar", async () => {
const verifyEmail = vi.fn();
await mountAt("/?login=out#reset=unspent", verifyEmail);
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");
});
// The flag must not outlive the token it describes. Navigating away from a `#reset=` fragment —
// a pasted verify link, the back button — leaves the address bar with no reset token, so a
// still-true flag promises a recovery that signing out can no longer complete AND (being checked
// first by VerifyBanner) hides whatever arrived in its place.
it("drops the reset notice once the fragment carrying it is gone", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
const outcome = await mountAt("/#reset=going-away", verifyEmail);
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
await act(async () => {
window.history.replaceState(window.history.state, "", "/#verify=tok-next");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(screen.getByTestId("reset").textContent).toBe("-");
expect(outcome()).toBe("ok:someone@example.com");
});
// …and a fragment change that carries neither token drops it too (the back button out of a
// reset link), which a one-way "set it when you see one" listener would not.
it("drops the reset notice when the fragment changes to no token at all", async () => {
await mountAt("/#reset=going-away", vi.fn());
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
await act(async () => {
window.history.replaceState(window.history.state, "", "/#somewhere-else");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
expect(screen.getByTestId("reset").textContent).toBe("-");
});
// A reset link can arrive over an unread confirmation (and a slow /auth/verify can resolve after
// one did), so both states can be set at once. Neither may eat the other: `dismiss` used to clear
// both, which is how the reset guidance could be destroyed before it was ever rendered.
it("keeps a pasted reset link and an unread confirmation, and dismisses them one at a time", async () => {
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
const outcome = await mountAt("/#verify=tok-first", verifyEmail);
expect(outcome()).toBe("ok:someone@example.com");
await act(async () => {
window.history.replaceState(window.history.state, "", "/#reset=pasted");
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
// Both live: the reset is pending AND the confirmation is still unread.
expect(screen.getByTestId("reset").textContent).toBe("reset-pending");
expect(outcome()).toBe("ok:someone@example.com");
// One dismiss peels the reset guidance and leaves the confirmation standing…
await act(async () => screen.getByTestId("dismiss").click());
expect(screen.getByTestId("reset").textContent).toBe("-");
expect(outcome()).toBe("ok:someone@example.com");
// …and the next one clears that.
await act(async () => screen.getByTestId("dismiss").click());
expect(outcome()).toBe("none:-");
});
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
// ?link=/?oauth= effects and the lazily-loaded module views read their own parameters. It used to
// blank the whole query and null history.state, which silently broke first-run setup (the wizard
// found no ?token=) and every signed-in deep link. It must only clear what it owns.
it("leaves parameters it does not own, and keeps history.state", async () => {
window.history.replaceState(
{ sfScroll: 42 },
"",
"/?token=setup-1&page=plex&v=show:9&login=out",
);
await mountAt(window.location.href, vi.fn());
const params = new URLSearchParams(window.location.search);
expect(params.get("token")).toBe("setup-1");
expect(params.get("page")).toBe("plex");
expect(params.get("v")).toBe("show:9");
expect(params.get("login")).toBeNull(); // a one-time notice, ours to drop
expect(window.history.state).toEqual({ sfScroll: 42 });
});
});
// 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" })
// 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");
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);
});
});