Files
siftlode/frontend/src/lib/verifyEntry.dom.test.tsx
T
peter 358d722ce9 fix(e12): close what round one broke, and revert two fixes that overreached
Round two of the review loop, on round one's own diff. Two of its findings were
regressions the fixes themselves introduced, which is exactly what the loop is for:

**A per-user refusal was writing onto a shared row.** MediaAssets are deduped across
users, and the generic failure handler puts the message on the asset — right for an
upstream failure, wrong for "your storage is full". Two users queuing the same video share
one asset, so the first user hitting their cap failed the second user's job with the first
one's private reason, telling someone with an empty library to free up space. The refusal
now raises `_OwnerRefused`, caught separately: the job fails, the asset goes back to
`pending` for whoever else holds it. Pinned by a test that drives `_download` directly —
the refusal happens before extraction, so nothing needs stubbing.

**The restore scripts restarted the app, undoing the restore.** `docker compose start`
re-runs the container command, and that runs `alembic upgrade head` — so on the rollback
path this documents as recommended (restore the dump, THEN pin the older tag) the newer
image immediately re-applied the migrations the restore had just undone, and on a FAILED
restore it put the app back on a half-restored schema. Both are the opposite of why the
stop was added. Nothing is restarted automatically now; the scripts say what to run and
when, and the docs' snippets say the same.

**Reverted rather than patched:** the OnboardingWizard toast (the wizard already renders
that failure inline, next to a Retry button — three recoveries for one failure), and
`useVerifyEntry`'s "each new link clears the other". The latter blanked the signed-out
card's only banner, where nothing renders the reset flag to replace it. The two states are
independent again; what changes is that `dismiss` peels ONE layer, which is what the
original finding was actually about.

Also: `Stats` uses the YouTube helper rather than the bare one, so the route's English 403
detail no longer beats the translated fallback under a Hungarian UI; `urlparse` guarded
alongside `.port` (an unclosed bracket raised just as late), the reason renamed `malformed`
since "bad port" misdescribes it; three docstrings narrowed to what the code actually does
(the worker's byte check is per-job, not an aggregate ledger, and not on the edit path;
the redirect residual leaks a reachability oracle, which the note had denied; the
invalidation helper does not cover suspend); the pasted Windows recipe wrapped so `throw`
can stop it; the bash one-liner's failure branch no longer exits 0; and the tests that were
missing for the round-one fixes — the account panel's error paths, the invalidation set,
and the shared-asset case above.

Gate green: vitest 685, pytest 950.
2026-09-05 04:02:57 +02:00

264 lines
12 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");
});
// 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);
});
});