feat: add browser auth shell
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user