CI / verify (pull_request) Successful in 1m7s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 49s
CI / migration-smoke (pull_request) Successful in 2m17s
CI / image-build (pull_request) Successful in 1m10s
CI / deploy-smoke (same-origin) (pull_request) Successful in 45s
CI / package-publication (pull_request) Successful in 57s
CI / deploy-smoke (split) (pull_request) Successful in 2m37s
The Dashboard had one address. The API served the page only at `/`, both reference proxies forwarded only `/`, and the open module lived in React state, so a refresh went back to the first module, nothing could be bookmarked, and Back left the Dashboard (DESIGN.md §2.7). ADR 0057 makes the address bar the source of truth: - `/` is a host-owned Overview, now the landing page; `/modules/<id>` mounts that module. An id the server's navigation does not list shows "This page isn't available" and loads nothing; any other Dashboard address is a host-owned "Page not found". - `/modules`, `/settings`, and `/account` are reserved whole, from one list (`@ekklesia/shell/dashboard-routes`) that the shell's router and the API's page routes both read. The API serves the same frozen page, ETag, and security headers anywhere inside them; look-alikes and other methods stay the sanitized 404. - A small host-owned history replaces the selected-module state, built in the entry point so it stays the only file touching `window`. Navigation items are real links; modified clicks are left to the browser. Focus moves to the page after an in-place change, and the tab is named after the page. - Sign-in still happens in place with no return value (ADR 0034), so a deep link opens its own page after sign-in. - Both reference Caddyfiles forward the new paths; the deploy smoke now reloads a module's address through the real proxy and requires it to mount again. Co-Authored-By: Claude Opus 5.5 <[email protected]>
541 lines
20 KiB
TypeScript
541 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 { createMemoryHostHistory } from "../src/browser/host-history.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}
|
|
history={createMemoryHostHistory("/")}
|
|
{...(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();
|
|
});
|
|
});
|