Files
siftlode/frontend/src/components/welcomeMode.dom.test.ts
T
peter 44f626f732 feat(admin): delete a whitelist entry, and stop hiding a broken mail setup
Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.

- DELETE /api/admin/invites/{id} with a delete control on both the pending
  requests and the previously-decided list. Deny keeps the row so the person
  cannot immediately re-ask; delete forgets the address. Refused with 409 while
  a User row exists for it, because is_allowed reads that row and removing it
  would revoke a live user's access with no other sign — the list ships
  has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
  records an email_undelivered notification for every admin when a message is
  skipped or rejected, naming the message that was lost, throttled to one an
  hour. /api/me carries email_configured, driving a standing warning on
  Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
  views are history entries (useHistorySubview), so Back returns to the previous
  view instead of only moving the address bar.

Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
2026-09-01 23:09:25 +02:00

94 lines
4.0 KiB
TypeScript

// @vitest-environment jsdom
//
// The login card's sub-views (Create an account / Forgot password / Didn't get the email) used to be
// plain React state: clicking one changed nothing in history, so Back popped to whatever entry came
// before the card and the address bar moved while the form on screen did not. They now ride
// useHistorySubview, and this pins the wiring decisions that are specific to THIS card — which modes
// are mirrored, and that Back walks the views. The hook's own mechanics live in
// history.subview.dom.test.ts.
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useHistorySubview } from "../lib/history";
import { MODE_URL, type Mode } from "./Welcome";
const render = (root: Mode = "signin") => renderHook(() => useHistorySubview<Mode>(root, MODE_URL));
// jsdom's history.back() is asynchronous and does not rewind location, so — as in
// history.subview.dom.test.ts — a Back is simulated by landing on the target entry and firing
// popstate. `open()` pushing a REAL entry is asserted separately via history.length.
const popTo = (search: string, state: unknown = null) =>
act(() => {
window.history.replaceState(state, "", search || "/");
window.dispatchEvent(new PopStateEvent("popstate", { state }));
});
// jsdom keeps one history across a file; land on a clean root entry before each test.
beforeEach(() => window.history.replaceState(null, "", "/"));
afterEach(() => window.history.replaceState(null, "", "/"));
describe("login card mode ↔ URL", () => {
it("mirrors the three link-reachable views and leaves sign-in as the bare root", () => {
expect(MODE_URL.encode("signin")).toBeNull();
expect(MODE_URL.encode("register")).toBe("register");
expect(MODE_URL.encode("forgot")).toBe("forgot");
expect(MODE_URL.encode("resend")).toBe("resend");
});
it("never mirrors the reset view", () => {
// Reset is only reachable with the one-time token from the #reset fragment. Mirroring it would
// hand out a bookmark that opens a password form with no token behind it.
expect(MODE_URL.encode("reset")).toBeNull();
expect(MODE_URL.decode("reset")).toBeNull();
});
it("decodes only real views, so a hand-typed ?m= falls back to sign-in", () => {
expect(MODE_URL.decode("register")).toBe("register");
expect(MODE_URL.decode("")).toBeNull();
expect(MODE_URL.decode("nonsense")).toBeNull();
});
it("opening a view pushes a real history entry, and landing back on the root restores sign-in", () => {
const { result } = render();
const entries = window.history.length;
expect(result.current.view).toBe("signin");
act(() => result.current.open("forgot"));
// The whole point: a Back has somewhere to go. Before this the click changed state only.
expect(window.history.length).toBe(entries + 1);
expect(result.current.view).toBe("forgot");
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
popTo("/");
expect(result.current.view).toBe("signin");
});
it("walks back one view at a time when the user hops between them", () => {
const { result } = render();
const entries = window.history.length;
act(() => result.current.open("register"));
act(() => result.current.open("forgot"));
expect(window.history.length).toBe(entries + 2); // one entry per view, not one for the lot
popTo("/?m=register", { _sub: "register" });
expect(result.current.view).toBe("register");
popTo("/");
expect(result.current.view).toBe("signin");
});
it("opens on the reset view when a reset token seeded it, without putting it in the URL", () => {
const { result } = render("reset");
expect(result.current.view).toBe("reset");
expect(window.location.search).toBe("");
});
it("restores a bookmarked view from the URL alone", () => {
window.history.replaceState(null, "", "/?m=resend");
const { result } = render();
expect(result.current.view).toBe("resend");
});
});