feat: add browser auth shell
CI / verify (pull_request) Successful in 38s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 1m0s

This commit is contained in:
ekklesia
2026-08-23 22:01:26 -05:00
parent 91aa9c2930
commit a8c7c50b4d
26 changed files with 2702 additions and 194 deletions
+639 -5
View File
@@ -18,6 +18,7 @@ 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);
@@ -134,6 +135,92 @@ 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 submitLogin(): Promise<void> {
const form = query("host-login")?.querySelector("form");
if (!form) {
throw new Error("missing login form");
}
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
interface StatefulAuthPlatform {
readonly calls: Array<{ readonly init: RequestInit | undefined; readonly url: string }>;
readonly fetch: typeof fetch;
authenticated(): boolean;
setAuthenticated(value: boolean): void;
}
function statefulAuthPlatform(
options: {
readonly initiallyAuthenticated?: boolean;
readonly moduleStatus?: () => number;
readonly onModuleRequest?: () => Promise<void>;
readonly onPrincipalRequest?: (authenticated: boolean) => Promise<void>;
readonly onSignIn?: () => Promise<void>;
readonly onSignOut?: () => Promise<void>;
} = {},
): StatefulAuthPlatform {
let authenticated = options.initiallyAuthenticated ?? false;
const calls: StatefulAuthPlatform["calls"] = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ init, url });
if (url.endsWith("/api/auth/sign-in/email")) {
await options.onSignIn?.();
const body = JSON.parse(String(init?.body)) as { email?: unknown; password?: unknown };
if (body.email !== "[email protected]" || body.password !== "correct password") {
return Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 });
}
authenticated = true;
return Response.json({ ok: true });
}
if (url.endsWith("/api/auth/sign-out")) {
await options.onSignOut?.();
authenticated = false;
return Response.json({ success: true });
}
if (url.endsWith("/api/v1/principal")) {
await options.onPrincipalRequest?.(authenticated);
return Response.json(
authenticated ? { kind: "authenticated", userId: "user-1" } : { kind: "anonymous" },
);
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return authenticated
? Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] })
: Response.json({ code: "AUTHORIZATION_DENIED" }, { status: 403 });
}
if (url.includes("/api/v1/modules/example/")) {
await options.onModuleRequest?.();
return new Response("{}", { status: options.moduleStatus?.() ?? 200 });
}
return Response.json({ code: "NOT_FOUND" }, { status: 404 });
}) as unknown as typeof fetch;
return {
calls,
fetch: implementation,
authenticated: () => authenticated,
setAuthenticated: (value) => {
authenticated = value;
},
};
}
describe("Ekklesia shell — bootstrap", () => {
it("renders the module list the navigation returned", async () => {
await render({
@@ -147,17 +234,19 @@ describe("Ekklesia shell — bootstrap", () => {
expect(query("select-calendar")).not.toBeNull();
});
it("asks an anonymous viewer to sign in, and mounts nothing", async () => {
it("renders the host login for an anonymous viewer, and mounts nothing", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ principal: { kind: "anonymous" } }) });
expect(query("host-signed-out")).not.toBeNull();
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
});
it("treats refused navigation as signed out rather than as a crash", async () => {
it("keeps an authenticated navigation denial distinct from sign-out", async () => {
await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) });
expect(query("host-signed-out")).not.toBeNull();
expect(query("host-access-denied")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
});
it("renders a host-owned error when the platform cannot be reached", async () => {
@@ -174,6 +263,549 @@ describe("Ekklesia shell — bootstrap", () => {
});
});
describe("Ekklesia shell — browser authentication", () => {
it("renders only a neutral loading state until principal resolves and fetches no navigation early", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
releasePrincipal = resolve;
});
const calls: string[] = [];
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/api/v1/principal")) {
await principalPending;
return Response.json({ kind: "anonymous" });
}
return Response.json({ groups: [], ungrouped: [] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation });
expect(query("host-loading")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).toBeNull();
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
await act(async () => {
releasePrincipal?.();
await principalPending;
});
expect(query("host-login")).not.toBeNull();
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
});
it("renders an accessible semantic email/password form with no signup or dead reset action", async () => {
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch });
const email = query("login-email") as HTMLInputElement;
const password = query("login-password") as HTMLInputElement;
const form = query("host-login")?.querySelector("form");
const submit = form?.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(email.type).toBe("email");
expect(email.autocomplete).toBe("username");
expect(email.labels?.[0]?.textContent).toContain("Email");
expect(password.type).toBe("password");
expect(password.autocomplete).toBe("current-password");
expect(password.labels?.[0]?.textContent).toContain("Password");
expect(submit.textContent).toBe("Sign in");
expect(form?.tagName).toBe("FORM");
expect(text()).not.toContain("Create account");
expect(text()).not.toContain("Forgot password");
});
it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => {
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "wrong and secret");
await submitLogin();
const error = query("host-login")?.querySelector('[role="alert"]');
expect(error?.textContent).toBe("Email or password is incorrect.");
expect((query("login-email") as HTMLInputElement).value).toBe("[email protected]");
expect((query("login-password") as HTMLInputElement).value).toBe("");
expect(document.activeElement).toBe(query("login-password"));
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("wrong and secret");
expect(consoleError).not.toHaveBeenCalled();
});
it("prevents duplicate submissions while one sign-in is pending", async () => {
let releaseSignIn: (() => void) | undefined;
const signInPending = new Promise<void>((resolve) => {
releaseSignIn = resolve;
});
const platform = statefulAuthPlatform({ onSignIn: () => signInPending });
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
await submitLogin();
const signInCalls = platform.calls.filter((call) =>
call.url.endsWith("/api/auth/sign-in/email"),
);
expect(signInCalls).toHaveLength(1);
expect(
query("host-login")?.querySelector('button[type="submit"]')?.hasAttribute("disabled"),
).toBe(true);
await act(async () => {
releaseSignIn?.();
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("clears the password while post-sign-in principal resolution is still pending", async () => {
let releasePrincipal: (() => void) | undefined;
const principalPending = new Promise<void>((resolve) => {
releasePrincipal = resolve;
});
const platform = statefulAuthPlatform({
onPrincipalRequest: (authenticated) => (authenticated ? principalPending : Promise.resolve()),
});
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(platform.authenticated()).toBe(true);
expect((query("login-password") as HTMLInputElement).value).toBe("");
expect(
JSON.parse(
String(
platform.calls.find((call) => call.url.endsWith("/api/auth/sign-in/email"))?.init?.body,
),
),
).toEqual({ email: "[email protected]", password: "correct password" });
expect(query("host-login")?.querySelector("form")?.getAttribute("aria-busy")).toBe("true");
await act(async () => {
releasePrincipal?.();
await principalPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("submits from the password field with the Enter key", async () => {
let releaseSignIn: (() => void) | undefined;
const signInPending = new Promise<void>((resolve) => {
releaseSignIn = resolve;
});
const platform = statefulAuthPlatform({ onSignIn: () => signInPending });
await render({ apiBaseUrl, fetch: platform.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await act(async () => {
query("login-password")?.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key: "Enter" }),
);
await Promise.resolve();
});
expect(
platform.calls.filter((call) => call.url.endsWith("/api/auth/sign-in/email")),
).toHaveLength(1);
expect((query("login-password") as HTMLInputElement).value).toBe("");
await act(async () => {
releaseSignIn?.();
await signInPending;
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
it("re-resolves principal and navigation after login without a page reload", async () => {
const platform = statefulAuthPlatform();
const importer = vi.fn(async () => artifact("example", "0.1.0"));
await render({ apiBaseUrl, fetch: platform.fetch }, importer);
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(query("host-login")).toBeNull();
expect(query("host-logout")).not.toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
expect(importer).toHaveBeenCalledOnce();
expect(platform.calls.map((call) => new URL(call.url).pathname)).toEqual([
"/api/v1/principal",
"/api/auth/sign-in/email",
"/api/v1/principal",
"/api/v1/dashboard/navigation",
]);
});
it("keeps the current internal URL in place and ignores external or protocol-relative return inputs", async () => {
window.history.replaceState(
{},
"",
"/?returnTo=%2F%2Fevil.example.test%2Fsteal&next=https%3A%2F%2Fevil.example.test&backslash=%5C%5Cevil.example.test&scheme=javascript%3Aalert%281%29",
);
const expectedLocation = window.location.href;
const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct password");
await submitLogin();
expect(window.location.href).toBe(expectedLocation);
expect(platform.calls.every((call) => new URL(call.url).origin === apiBaseUrl)).toBe(true);
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("evil.example.test");
expect(platform.calls.map((call) => call.url).join("\n")).not.toContain("javascript");
});
it("shows a safe retry when principal resolution is malformed or unavailable", async () => {
let healthy = false;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return healthy
? Response.json({ kind: "authenticated", userId: "user-1" })
: new Response("database failed at /private/path", { status: 503 });
}
return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation }, async () =>
artifact("example", "0.1.0"),
);
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("private");
healthy = true;
const retry = query("host-error")?.querySelector("button");
expect(retry).not.toBeNull();
await act(async () => {
(retry as HTMLButtonElement).click();
});
expect(query("host-logout")).not.toBeNull();
});
it("fails safely on a malformed successful principal response and recovers on retry", async () => {
let healthy = false;
const fetchImplementation = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return Response.json(
healthy
? { kind: "authenticated", userId: "user-1" }
: { kind: "authenticated", privatePath: "/sessions/private" },
);
}
return Response.json({ groups: [], ungrouped: [entry("example", "Example Module")] });
}) as unknown as typeof fetch;
await render({ apiBaseUrl, fetch: fetchImplementation }, async () =>
artifact("example", "0.1.0"),
);
expect(query("host-error")).not.toBeNull();
expect(text()).not.toContain("private");
expect(query("host-login")).toBeNull();
healthy = true;
const retry = query("host-error")?.querySelector("button");
expect(retry).not.toBeNull();
await act(async () => {
(retry as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-logout")).not.toBeNull();
});
});
describe("Ekklesia shell — logout and session expiration", () => {
it("unmounts module UI and closes realtime before completing server-side logout", async () => {
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
releaseSignOut = resolve;
});
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
onSignOut: () => signOutPending,
});
let unmountCalls = 0;
let closeCalls = 0;
const source = {
CLOSED: 2,
CONNECTING: 0,
OPEN: 1,
addEventListener: vi.fn(),
close: () => {
closeCalls += 1;
},
readyState: 0,
removeEventListener: vi.fn(),
} as unknown as EventSource;
await render(
{
apiBaseUrl,
eventSourceFactory: () => source,
fetch: platform.fetch,
},
async () => ({
default: {
id: "example",
mount(element: Element, context: ModuleUiHostContext) {
element.textContent = "example mounted";
const subscription = context.realtime.subscribe("state", {});
return () => {
unmountCalls += 1;
subscription.close();
};
},
version: "0.1.0",
},
}),
);
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-signing-out")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(unmountCalls).toBe(1);
expect(closeCalls).toBe(1);
expect(platform.authenticated()).toBe(true);
await act(async () => {
releaseSignOut?.();
await signOutPending;
await Promise.resolve();
});
expect(platform.authenticated()).toBe(false);
expect(query("host-login")).not.toBeNull();
// A new shell resolves the invalidated server session as anonymous too.
await act(async () => root.unmount());
root = createRoot(container);
await render({ apiBaseUrl, fetch: platform.fetch });
expect(query("host-login")).not.toBeNull();
});
it("ignores a retained module request that resolves after logout unmounts its frame", async () => {
let authenticatedPrincipalRequests = 0;
let releaseModuleRequest: (() => void) | undefined;
const moduleRequestPending = new Promise<void>((resolve) => {
releaseModuleRequest = resolve;
});
let releaseSignOut: (() => void) | undefined;
const signOutPending = new Promise<void>((resolve) => {
releaseSignOut = resolve;
});
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
onModuleRequest: () => moduleRequestPending,
onPrincipalRequest: (authenticated) => {
if (!authenticated) {
return Promise.resolve();
}
authenticatedPrincipalRequests += 1;
return authenticatedPrincipalRequests === 1
? Promise.resolve()
: Promise.reject(new Error("stale revalidation must never run"));
},
onSignOut: () => signOutPending,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
const lateRequest = capture.context?.api.request("retained-after-unmount");
if (!lateRequest) {
throw new Error("module context was not captured");
}
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-signing-out")).not.toBeNull();
expect(capture.unmountCalls).toBe(1);
expect(platform.authenticated()).toBe(true);
await act(async () => {
releaseModuleRequest?.();
await lateRequest;
await Promise.resolve();
});
// The 401 belongs to a request retained by an already-unmounted module. It must not abort or
// replace the newer sign-out transition while the server still owns an authenticated session.
expect(query("host-signing-out")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(platform.authenticated()).toBe(true);
expect(authenticatedPrincipalRequests).toBe(1);
await act(async () => {
releaseSignOut?.();
await signOutPending;
await Promise.resolve();
});
expect(platform.authenticated()).toBe(false);
expect(query("host-login")).not.toBeNull();
});
it("does not claim logout succeeded when session invalidation fails", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
onSignOut: () => Promise.reject(new Error("private session failure")),
});
await render({ apiBaseUrl, fetch: platform.fetch }, async () => artifact("example", "0.1.0"));
await act(async () => {
(query("host-logout") as HTMLButtonElement).click();
await Promise.resolve();
});
expect(query("host-sign-out-error")).not.toBeNull();
expect(query("host-login")).toBeNull();
expect(query("module-container")).toBeNull();
expect(text()).not.toContain("private session failure");
});
function sessionAwareArtifact(
capture: { context?: ModuleUiHostContext; unmountCalls: number },
options: { readonly subscribeToRealtime?: boolean } = {},
) {
return {
default: {
id: "example",
mount(element: Element, context: ModuleUiHostContext) {
capture.context = context;
element.textContent = "example mounted";
const subscription = options.subscribeToRealtime
? context.realtime.subscribe("state", {})
: undefined;
return () => {
capture.unmountCalls += 1;
subscription?.close();
element.textContent = "";
};
},
version: "0.1.0",
},
};
}
it("returns to login when a module API 401 recheck proves the session became anonymous", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
});
let closeCalls = 0;
const source = {
CLOSED: 2,
CONNECTING: 0,
OPEN: 1,
addEventListener: vi.fn(),
close: () => {
closeCalls += 1;
},
readyState: 0,
removeEventListener: vi.fn(),
} as unknown as EventSource;
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render(
{
apiBaseUrl,
eventSourceFactory: () => source,
fetch: platform.fetch,
},
async () => sessionAwareArtifact(capture, { subscribeToRealtime: true }),
);
platform.setAuthenticated(false);
const principalCallsBefore = platform.calls.filter((call) =>
call.url.endsWith("/api/v1/principal"),
).length;
await act(async () => {
await capture.context?.api.request("expired");
});
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(capture.unmountCalls).toBe(1);
expect(closeCalls).toBe(1);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(
principalCallsBefore + 1,
);
});
it("preserves an authenticated session after an ordinary module API 401", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 401,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
await act(async () => {
const response = await capture.context?.api.request("module-authentication-required");
expect(response?.status).toBe(401);
await Promise.resolve();
});
expect(query("host-login")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
});
it("rechecks but does not log out an authenticated principal after an ordinary 403", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 403,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
await act(async () => {
const response = await capture.context?.api.request("forbidden");
expect(response?.status).toBe(403);
await Promise.resolve();
});
expect(query("host-login")).toBeNull();
expect(query("module-container")?.textContent).toBe("example mounted");
expect(capture.unmountCalls).toBe(0);
expect(platform.calls.filter((call) => call.url.endsWith("/api/v1/principal"))).toHaveLength(2);
});
it("returns to login when a 403 recheck proves the session became anonymous", async () => {
const platform = statefulAuthPlatform({
initiallyAuthenticated: true,
moduleStatus: () => 403,
});
const capture: { context?: ModuleUiHostContext; unmountCalls: number } = { unmountCalls: 0 };
await render({ apiBaseUrl, fetch: platform.fetch }, async () => sessionAwareArtifact(capture));
platform.setAuthenticated(false);
await act(async () => {
await capture.context?.api.request("forbidden-after-expiry");
await Promise.resolve();
});
expect(query("host-login")).not.toBeNull();
expect(query("module-container")).toBeNull();
expect(capture.unmountCalls).toBe(1);
});
});
describe("Ekklesia shell — Dashboard navigation", () => {
const mediaGroup = () =>
group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]);
@@ -209,7 +841,9 @@ describe("Ekklesia shell — Dashboard navigation", () => {
const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent);
expect(labels).toEqual(["Planning", "Media"]);
const buttons = [...container.querySelectorAll("button")].map((button) => button.textContent);
const buttons = [...container.querySelectorAll("nav button")].map(
(button) => button.textContent,
);
expect(buttons).toEqual(["Calendar", "Livestream", "Video"]);
});