Files
Ekklesia/surfaces/shell/tests/auth-recovery.test.tsx
T
ekklesiaandClaude Opus 5.5 a12cbe9600
CI / verify (pull_request) Successful in 46s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m44s
feat(api): serve the built Dashboard from the API origin
Serve surfaces/shell's built Dashboard from apps/api on the same origin
as /api (ADR 0044), exactly as the display host is served: a closed set
of files read once at startup, never a static directory, never a path
built from request input.

- The shell exports @ekklesia/shell/dashboard-assets (the exact built
  index.html and script URLs), emits the script at
  assets/ekklesia-dashboard/app.js, and index.html names that absolute
  path so dev and production serve the same URLs.
- GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen
  snapshot: no-cache with a strong ETag and 304 on match. Source maps
  are never served; every other path is the ordinary sanitized 404.
- The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy:
  frame-ancestors 'none' (that directive only), and Referrer-Policy:
  no-referrer, on 200 and 304 alike.
- A missing or empty shell build fails startup with a fixed
  DashboardHostAssetError before any module code is imported.
- The If-None-Match comparison moves to conditional-request.ts, shared
  with the display host routes.
- The emailed-link token stripping moves from browser/main.tsx into
  consumeAuthLanding (location and history injected) so it is tested;
  behavior is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-01 17:54:55 -05:00

534 lines
20 KiB
TypeScript

import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeAuthLanding, type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/**
* The host-owned recovery and verification screens (ADR 0038).
*
* Everything asserted here is a property of the *host's* browser surface: that an unverified account
* never reads as authenticated, that a reset token is never persisted or rendered, that a query
* string can only ever select among Ekklesia's own screens, and that no failure message carries a
* provider detail. The server-side behaviour these screens sit on top of is proven separately, end
* to end, in `apps/api/tests/email-verification.integration.ts`.
*/
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
window.history.replaceState({}, "", "/");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
localStorage.clear();
sessionStorage.clear();
});
interface RecordedCall {
readonly url: string;
readonly body: string;
}
interface AuthStub {
/** Status for `/api/auth/sign-in/email`. 403 with `EMAIL_NOT_VERIFIED` is the unverified case. */
readonly signIn?: () => Response;
readonly requestPasswordReset?: () => Response;
readonly resetPassword?: () => Response;
readonly sendVerificationEmail?: () => Response;
}
interface Platform {
readonly fetch: typeof fetch;
readonly calls: RecordedCall[];
}
function platform(stub: AuthStub = {}): Platform {
const calls: RecordedCall[] = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ body: String(init?.body ?? ""), url });
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "anonymous" });
}
if (url.endsWith("/api/auth/sign-in/email")) {
return (
stub.signIn?.() ?? Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 })
);
}
if (url.endsWith("/api/auth/request-password-reset")) {
return (
stub.requestPasswordReset?.() ??
Response.json({ message: "If this email exists in our system…", status: true })
);
}
if (url.endsWith("/api/auth/reset-password")) {
return stub.resetPassword?.() ?? Response.json({ status: true });
}
if (url.endsWith("/api/auth/send-verification-email")) {
return stub.sendVerificationEmail?.() ?? Response.json({ status: true });
}
return new Response("{}", { status: 404 });
});
return { calls, fetch: implementation as unknown as typeof fetch };
}
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
});
}
function text(): string {
return container.textContent ?? "";
}
function query(testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function setInput(testId: string, value: string): Promise<void> {
const input = query(testId) as HTMLInputElement | null;
if (!input) {
throw new Error(`missing input ${testId}`);
}
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function click(testId: string): Promise<void> {
const element = query(testId) as HTMLElement | null;
if (!element) {
throw new Error(`missing element ${testId}`);
}
await act(async () => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
async function submit(hostTestId: string): Promise<void> {
const form = query(hostTestId)?.querySelector("form");
if (!form) {
throw new Error(`missing form in ${hostTestId}`);
}
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
describe("consuming an emailed-link landing", () => {
it("captures a reset token and strips the whole query from the address bar in place", () => {
window.history.replaceState({}, "", "/?ekklesia_auth=reset-password&token=secret-reset-token");
const lengthBefore = window.history.length;
const landing = consumeAuthLanding(window.location, window.history);
expect(landing).toEqual({ kind: "reset-password", token: "secret-reset-token" });
expect(window.location.search).toBe("");
expect(window.location.href).not.toContain("secret-reset-token");
// Replaced, not pushed: Back cannot return to the entry that held the token.
expect(window.history.length).toBe(lengthBefore);
});
it("strips every recognized landing, including a failed verification and an invalid reset link", () => {
for (const search of [
"?ekklesia_auth=verify-email",
"?ekklesia_auth=verify-email&error=TOKEN_EXPIRED",
"?ekklesia_auth=reset-password&error=INVALID_TOKEN",
"?ekklesia_auth=reset-password&token=not%20a%20valid%20token",
]) {
window.history.replaceState({}, "", `/${search}`);
expect(consumeAuthLanding(window.location, window.history), search).toBeDefined();
expect(window.location.search, search).toBe("");
}
});
it("leaves an unrelated query untouched and rewrites nothing", () => {
window.history.replaceState({}, "", "/?unrelated=1");
const replaceState = vi.fn();
expect(consumeAuthLanding(window.location, { replaceState })).toBeUndefined();
expect(replaceState).not.toHaveBeenCalled();
expect(window.location.search).toBe("?unrelated=1");
});
it("still returns the captured landing when the browser refuses the rewrite", () => {
const replaceState = vi.fn(() => {
throw new Error("SecurityError");
});
expect(
consumeAuthLanding(
{ pathname: "/", search: "?ekklesia_auth=reset-password&token=abc123" },
{ replaceState },
),
).toEqual({ kind: "reset-password", token: "abc123" });
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
});
describe("emailed-link landing parsing", () => {
it("recognizes exactly Ekklesia's own two landings", () => {
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
expect(readAuthLanding("?ekklesia_auth=verify-email&error=TOKEN_EXPIRED")).toEqual({
expired: true,
kind: "verification-failed",
});
expect(readAuthLanding("?ekklesia_auth=verify-email&error=INVALID_TOKEN")).toEqual({
expired: false,
kind: "verification-failed",
});
expect(readAuthLanding("?ekklesia_auth=reset-password&token=abc123")).toEqual({
kind: "reset-password",
token: "abc123",
});
expect(readAuthLanding("?ekklesia_auth=reset-password&error=INVALID_TOKEN")).toEqual({
kind: "reset-link-invalid",
});
});
it("ignores anything that is not one of them, so a query cannot steer the shell", () => {
for (const search of [
"",
"?",
"?token=abc123",
"?ekklesia_auth=dashboard",
"?ekklesia_auth=",
"?redirect=https://evil.example.test",
"?ekklesia_auth[]=verify-email",
]) {
expect(readAuthLanding(search)).toBeUndefined();
}
});
it("treats a reset landing with no usable token as an invalid link, never as a token", () => {
for (const search of [
"?ekklesia_auth=reset-password",
"?ekklesia_auth=reset-password&token=",
"?ekklesia_auth=reset-password&token=has%20space",
`?ekklesia_auth=reset-password&token=${"a".repeat(257)}`,
]) {
expect(readAuthLanding(search)).toEqual({ kind: "reset-link-invalid" });
}
});
it("names no destination of any kind, so there is nothing to redirect to", () => {
const landing = readAuthLanding(
"?ekklesia_auth=verify-email&callbackURL=https://evil.example.test&next=//evil.example.test",
);
expect(landing).toEqual({ kind: "verified" });
expect(JSON.stringify(landing)).not.toContain("evil");
});
});
describe("unverified sign-in", () => {
const unverified = (): Response =>
Response.json({ code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }, { status: 403 });
it("offers verification instead of a credentials error, and never renders the Dashboard", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
expect(query("host-verification-required")).not.toBeNull();
expect(query("host-login")).toBeNull();
// Not "your password is wrong": it was right. And not the Dashboard either — the server minted
// no session, so nothing about this state is authenticated.
expect(text()).not.toContain("Email or password is incorrect");
expect(text()).toContain("Your password was accepted");
expect(container.querySelector("aside")).toBeNull();
});
it("resends from the address the person just typed, and says nothing about whether it exists", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
await submit("host-verification-required");
const resend = stub.calls.find((call) =>
call.url.endsWith("/api/auth/send-verification-email"),
);
expect(resend?.body).toBe(JSON.stringify({ email: "[email protected]" }));
expect(query("verification-resent")).not.toBeNull();
// The wording is a conditional, not a confirmation that an account exists.
expect(text()).toContain("If an unverified Ekklesia account exists");
});
it("keeps the password out of every request but the sign-in itself", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
await submit("host-verification-required");
for (const call of stub.calls) {
expect(call.url).not.toContain("correct horse");
if (!call.url.endsWith("/api/auth/sign-in/email")) {
expect(call.body).not.toContain("correct horse");
}
}
});
});
describe("forgot password", () => {
it("reaches the recovery form from the sign-in screen and back again", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
expect(query("host-forgot-password")).not.toBeNull();
await click("forgot-cancel");
expect(query("host-login")).not.toBeNull();
});
it("shows one generic completion state, whatever the address", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
expect(query("host-forgot-password-sent")).not.toBeNull();
expect(text()).toContain("If an Ekklesia account exists for that address");
// Nothing on the screen distinguishes an address with an account from one without.
expect(text()).not.toContain("[email protected]");
expect(text()).not.toContain("No account");
});
it("submits once even when the form is submitted repeatedly", async () => {
let release: (() => void) | undefined;
const stub = platform({
requestPasswordReset: () => Response.json({ status: true }),
});
const gated = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith("/api/auth/request-password-reset")) {
await new Promise<void>((resolve) => {
release = resolve;
});
}
return (stub.fetch as unknown as typeof fetch)(input, init);
});
await render({ apiBaseUrl, fetch: gated as unknown as typeof fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
await submit("host-forgot-password");
await submit("host-forgot-password");
release?.();
await act(async () => {
await Promise.resolve();
});
// Every one of these triggers an email. A duplicate is not merely untidy.
const requests = stub.calls.filter((call) =>
call.url.endsWith("/api/auth/request-password-reset"),
);
expect(requests).toHaveLength(1);
});
it("reports a transport failure without a provider detail or a URL", async () => {
const stub = platform({
requestPasswordReset: () => new Response("relay 421 at smtp.example.org", { status: 500 }),
});
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
expect(text()).toContain("Unable to sign in right now.");
expect(text()).not.toContain("smtp.example.org");
expect(text()).not.toContain("421");
expect(text()).not.toContain(apiBaseUrl);
});
});
describe("reset password", () => {
const landing: HostAuthLanding = { kind: "reset-password", token: "reset-token-abc" };
it("sets a new password and returns cleanly to sign-in", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
expect(query("host-reset-password")).not.toBeNull();
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
const request = stub.calls.find((call) => call.url.endsWith("/api/auth/reset-password"));
expect(JSON.parse(request?.body ?? "{}")).toEqual({
newPassword: "a brand new passphrase",
token: "reset-token-abc",
});
expect(query("host-reset-password-complete")).not.toBeNull();
expect(text()).toContain(
"every existing Ekklesia session for this account has been signed out",
);
await click("reset-return-to-sign-in");
expect(query("host-login")).not.toBeNull();
});
it("blocks a mismatched confirmation in the browser, before anything is sent", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrasf");
await submit("host-reset-password");
expect(text()).toContain("The two passwords do not match.");
expect(stub.calls.some((call) => call.url.endsWith("/api/auth/reset-password"))).toBe(false);
});
it("surfaces the server's password policy without inventing one of its own", async () => {
const stub = platform({
resetPassword: () => Response.json({ code: "PASSWORD_TOO_SHORT" }, { status: 400 }),
});
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "short");
await setInput("reset-password-confirmation", "short");
await submit("host-reset-password");
// The server stays authoritative; the browser only echoes the bounds it was configured with.
expect(text()).toContain("between 8 and 128 characters");
const field = query("reset-password") as HTMLInputElement;
expect(field.minLength).toBe(8);
expect(field.maxLength).toBe(128);
});
it("reports a spent or expired token as a link problem, not a password problem", async () => {
const stub = platform({
resetPassword: () => Response.json({ code: "INVALID_TOKEN" }, { status: 400 }),
});
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
expect(text()).toContain("This password reset link is no longer valid");
});
it("never persists the token, and never renders it", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
// A reset token is a bearer credential for one account. It lives in React state for the life of
// the form and nowhere else.
expect(localStorage.length).toBe(0);
expect(sessionStorage.length).toBe(0);
expect(text()).not.toContain("reset-token-abc");
expect(container.innerHTML).not.toContain("reset-token-abc");
expect(window.location.search).toBe("");
for (const call of stub.calls) {
expect(call.url).not.toContain("reset-token-abc");
}
});
it("shows a dead-end-free screen for an invalid reset link", async () => {
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "reset-link-invalid" });
expect(query("host-reset-link-invalid")).not.toBeNull();
expect(text()).toContain("Password reset links last one hour and can be used once");
await click("reset-invalid-continue");
expect(query("host-login")).not.toBeNull();
});
});
describe("verification landing", () => {
it("confirms success and offers sign-in, without pretending to be signed in", async () => {
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "verified" });
expect(query("host-verification-succeeded")).not.toBeNull();
expect(text()).toContain("This address is confirmed");
// Following an emailed link mints no session, so the Dashboard must not appear.
expect(container.querySelector("aside")).toBeNull();
await click("verified-continue");
expect(query("host-login")).not.toBeNull();
});
it("says an expired link has expired, which is the actionable version", async () => {
await render(
{ apiBaseUrl, fetch: platform().fetch },
{ expired: true, kind: "verification-failed" },
);
expect(query("host-verification-failed")).not.toBeNull();
expect(text()).toContain("This link has expired");
expect(text()).toContain("Ekklesia will send a new one");
});
it("says an unusable link is invalid rather than guessing that it expired", async () => {
await render(
{ apiBaseUrl, fetch: platform().fetch },
{ expired: false, kind: "verification-failed" },
);
expect(text()).toContain("This link is not valid");
expect(text()).not.toContain("has expired");
});
it("still renders the Dashboard for someone who is already signed in", async () => {
const authenticated = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return Response.json({ groups: [], ungrouped: [] });
}
return new Response("{}", { status: 404 });
});
await render(
{ apiBaseUrl, fetch: authenticated as unknown as typeof fetch },
{ kind: "verified" },
);
// The principal is still what decides. A landing parameter never promotes anyone.
expect(query("host-verification-succeeded")).toBeNull();
expect(query("host-login")).toBeNull();
});
});