import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { 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; 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 { await act(async () => { root.render(); }); } 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 { 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 { 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 { 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("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", "member@example.org"); 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", "member@example.org"); 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: "member@example.org" })); 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", "member@example.org"); 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", "stranger@example.org"); 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("stranger@example.org"); 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((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", "member@example.org"); 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", "member@example.org"); 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(); }); });