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.
297 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|