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.
94 lines
4.0 KiB
TypeScript
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");
|
|
});
|
|
});
|