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]>
534 lines
20 KiB
TypeScript
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();
|
|
});
|
|
});
|