Files
Ekklesia/apps/api/tests/api-http.integration.ts
T
ekklesia 510d76f7c2
CI / verify (pull_request) Successful in 26s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 27s
docs: correct module authorization comments
2026-07-24 17:59:48 -05:00

1406 lines
54 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { request as httpRequest } from "node:http";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
import {
bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase,
createAuthorizationStore,
} from "@ekklesia/core/authorization";
import { diagnoseEkklesia } from "@ekklesia/core/diagnostics";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it, vi } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { startApi, type RunningApi } from "../src/server.js";
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const coreMigrationsDirectory = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"..",
"core",
"drizzle",
);
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function harnessError(message: string): IntegrationHarnessError {
return new IntegrationHarnessError(message);
}
function baseDatabaseUrl(): string {
const value = process.env[integrationDatabaseUrlEnvironment];
if (typeof value !== "string" || value.trim() === "") {
throw harnessError("The API integration database is not configured.");
}
try {
const parsed = new URL(value);
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
throw new TypeError("Unsupported protocol.");
}
} catch {
throw harnessError("The API integration database configuration is invalid.");
}
return value;
}
function quoteDatabaseName(databaseName: string): string {
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
throw harnessError("The generated integration database name is invalid.");
}
return `"${databaseName}"`;
}
function makeDatabaseName(label: string): string {
const safeLabel = label
.toLowerCase()
.replaceAll(/[^a-z0-9]+/gu, "_")
.replaceAll(/^_+|_+$/gu, "")
.slice(0, 14);
databaseCounter += 1;
return `ekk_api_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
}
function databaseUrl(databaseName: string): string {
const parsed = new URL(baseDatabaseUrl());
parsed.pathname = `/${databaseName}`;
parsed.searchParams.delete("application_name");
return parsed.toString();
}
async function openAdministrativeClient(connectionString: string): Promise<Client> {
const client = new Client({ connectionString });
client.on("error", () => undefined);
try {
await client.connect();
return client;
} catch {
try {
await client.end();
} catch {
// The public harness failure remains static when connection cleanup also fails.
}
throw harnessError("The API integration database connection failed.");
}
}
async function safeQuery<Row extends QueryResultRow>(
client: Client,
text: string,
values: unknown[] = [],
): Promise<readonly Row[]> {
try {
const result = await client.query<Row>(text, values);
return result.rows;
} catch {
throw harnessError("An integration database administration operation failed.");
}
}
async function safeEnd(client: Client): Promise<void> {
try {
await client.end();
} catch {
throw harnessError("An integration database connection could not be closed.");
}
}
async function createDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
} finally {
await safeEnd(admin);
}
}
async function dropDatabase(databaseName: string): Promise<void> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
await safeQuery(
admin,
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
[databaseName],
);
await safeQuery(
admin,
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
);
trackedDatabases.delete(databaseName);
} finally {
await safeEnd(admin);
}
}
async function activeConnectionCount(databaseName: string): Promise<number> {
const admin = await openAdministrativeClient(baseDatabaseUrl());
try {
const rows = await safeQuery<{ count: string }>(
admin,
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
[databaseName],
);
return Number(rows[0]?.count ?? "0");
} finally {
await safeEnd(admin);
}
}
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
const name = makeDatabaseName(label);
await createDatabase(name);
trackedDatabases.add(name);
return { name, url: databaseUrl(name) };
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
// Phase 4A's and Phase 4C pass 1's committed migrations must not have drifted while building
// this transport slice.
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables",
"core:0001_authorization_core_tables",
]);
expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
);
expect(aggregate.migrations[1]?.checksum).toBe(
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
const trackedModulesDirectories: string[] = [];
async function createFixtureModulesDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-installed-modules-"));
trackedModulesDirectories.push(directory);
return directory;
}
/** Writes a minimal, structurally valid module fixture straight to disk — real discovery and
* composition run against it, nothing hand-built. The server entrypoint throws immediately if
* ever imported/executed, so a scenario that completes without that throw firing is direct
* evidence permission composition and Doctor never ran module lifecycle code. */
async function createFixtureModule(
modulesDirectory: string,
id: string,
manifestOverrides: Record<string, unknown> = {},
): Promise<void> {
const moduleDirectory = path.join(modulesDirectory, id);
const manifest = {
manifestVersion: 3,
id,
name: `${id} module`,
version: "0.1.0",
compatibility: { platform: "^0.1.0", moduleSdk: "^0.1.0" },
permissions: [],
entrypoints: { server: "./dist/server/index.js" },
...manifestOverrides,
};
await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true });
await Promise.all([
writeFile(
path.join(moduleDirectory, "package.json"),
JSON.stringify({
ekklesia: { layer: "module" },
name: `@test/module-${id}`,
type: "module",
version: "0.1.0",
}),
),
writeFile(path.join(moduleDirectory, "ekklesia.module.json"), JSON.stringify(manifest)),
writeFile(
path.join(moduleDirectory, "dist/server/index.js"),
'throw new Error("permission composition and Doctor must never execute a module server entrypoint");',
),
]);
}
afterAll(async () => {
for (const databaseName of [...trackedDatabases]) {
await dropDatabase(databaseName).catch(() => undefined);
}
for (const directory of trackedModulesDirectories.splice(0)) {
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
}
});
const identityBaseUrl = "https://identity.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test";
function apiOrigin(running: RunningApi): string {
const address = running.server.address();
if (!address || typeof address !== "object") {
throw harnessError("The API integration server has no bound address.");
}
return `http://127.0.0.1:${address.port}`;
}
async function startMigratedApi(
url: string,
modulesDirectoryOverride?: string,
): Promise<RunningApi> {
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin],
},
port: 0,
},
{ environment: "production" },
);
// Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets
// either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise
// reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point
// permission composition at a real, disposable module fixture instead of this repository's own
// (empty) `modules/` directory.
return startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },
...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }),
});
}
function setCookieValue(response: Response): string | undefined {
return response.headers.get("set-cookie") ?? undefined;
}
function firstCookiePair(setCookieHeader: string): string {
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
}
interface SignUpResult {
readonly response: Response;
readonly body: { token: string | null; user: { id: string; email: string } };
}
async function signUp(
origin: string,
email: string,
password: string,
headers: Record<string, string> = {},
): Promise<SignUpResult> {
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({ email, name: "Integration Test User", password }),
// A trusted Origin by default, matching signIn below: Node's own fetch() (and every browser)
// automatically sends `Sec-Fetch-Mode: cors`, which Better Auth's first-login CSRF protection
// treats as "this is a Fetch-API call" and requires a trusted Origin/Referer for regardless of
// whether a cookie is present — see the dedicated no-Origin scenario below, which uses a raw
// `http.request` instead of `fetch()` to exercise a genuinely Fetch-Metadata-free caller.
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
});
return { body: (await response.clone().json()) as SignUpResult["body"], response };
}
interface SignInResult {
readonly cookiePair: string | undefined;
readonly response: Response;
}
async function signIn(
origin: string,
email: string,
password: string,
headers: Record<string, string> = {},
): Promise<SignInResult> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }),
headers: {
"content-type": "application/json",
origin: dashboardOrigin,
...headers,
},
method: "POST",
});
const setCookie = setCookieValue(response);
return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response };
}
async function fetchPrincipal(
origin: string,
headers: Record<string, string> = {},
): Promise<{ body: unknown; response: Response }> {
const response = await fetch(`${origin}/api/v1/principal`, { headers });
return { body: await response.clone().json(), response };
}
/**
* A raw `node:http` POST, deliberately not `fetch()`. Node's own `fetch()` (undici) — like every
* browser — automatically sends `Sec-Fetch-Mode: cors` on every request, which Better Auth's
* first-login CSRF protection (`formCsrfMiddleware`) treats as "this came from a Fetch-API
* client" and requires a trusted Origin/Referer for regardless of cookie presence. A genuine
* non-browser, non-Fetch server-to-server integration (curl, most backend HTTP client libraries,
* or this raw socket) sends none of the Fetch Metadata headers, so it is unaffected by that
* specific protection and needs no Origin header at all.
*/
function rawPostRequest(
origin: string,
requestPath: string,
body: unknown,
): Promise<{ body: string; status: number }> {
return new Promise((resolve, reject) => {
const url = new URL(requestPath, origin);
const payload = JSON.stringify(body);
const req = httpRequest(
url,
{
headers: {
"content-length": Buffer.byteLength(payload),
"content-type": "application/json",
},
method: "POST",
},
(res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk) => chunks.push(chunk as Buffer));
res.on("end", () => {
resolve({ body: Buffer.concat(chunks).toString("utf8"), status: res.statusCode ?? 0 });
});
},
);
req.on("error", reject);
req.end(payload);
});
}
describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
const emailOne = "[email protected]";
const emailTwo = "[email protected]";
const password = "correct horse battery staple";
it("applies migrations and starts the API against a freshly migrated database", async () => {
database = await createDisposableDatabase("signup_flow");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url);
origin = apiOrigin(running);
expect(running.server.listening).toBe(true);
});
it("returns the anonymous principal shape before any sign-in", async () => {
const { body, response } = await fetchPrincipal(origin);
expect(response.status).toBe(200);
expect(body).toEqual({ kind: "anonymous" });
});
it("publicly creates the first ordinary identity via sign-up, with no automatic session (autoSignIn: false)", async () => {
const { body, response } = await signUp(origin, emailOne, password);
expect(response.status).toBe(200);
expect(body.token).toBeNull();
expect(body.user.email).toBe(emailOne);
expect(setCookieValue(response)).toBeUndefined();
});
it("does not sign in automatically after sign-up", async () => {
const { body } = await fetchPrincipal(origin);
expect(body).toEqual({ kind: "anonymous" });
});
it("rejects wrong credentials without leaking raw internal information", async () => {
const { response } = await signIn(origin, emailOne, "wrong password entirely");
const text = await response.text();
expect(response.status).toBe(401);
expect(text).not.toContain(database.url);
expect(text).not.toContain("postgres");
expect(text.toLowerCase()).not.toContain("stack");
});
let sessionCookie: string;
it("signs in explicitly and returns a valid, host-only, HttpOnly, SameSite=Lax, secure session cookie", async () => {
const { cookiePair, response } = await signIn(origin, emailOne, password);
const setCookie = setCookieValue(response);
expect(response.status).toBe(200);
expect(setCookie).toBeDefined();
expect(cookiePair).toBeDefined();
sessionCookie = cookiePair as string;
const cookieText = setCookie as string;
expect(cookieText).toContain("HttpOnly");
expect(cookieText).toContain("SameSite=Lax");
expect(cookieText).toContain("Secure");
expect(cookieText).not.toMatch(/domain=/iu);
});
it("resolves the authenticated principal containing only kind and the opaque user id", async () => {
const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie });
expect(response.status).toBe(200);
expect(body).toMatchObject({ kind: "authenticated" });
const record = body as Record<string, unknown>;
expect(Object.keys(record).sort()).toEqual(["kind", "userId"]);
expect(typeof record.userId).toBe("string");
});
it("resolves a malformed cookie as anonymous rather than failing", async () => {
const { body, response } = await fetchPrincipal(origin, {
cookie: "better-auth.session_token=not-a-real-token",
});
expect(response.status).toBe(200);
expect(body).toEqual({ kind: "anonymous" });
});
it("resolves a nonexistent cookie name as anonymous", async () => {
const { body, response } = await fetchPrincipal(origin, { cookie: "unrelated=value" });
expect(response.status).toBe(200);
expect(body).toEqual({ kind: "anonymous" });
});
it("does not create a second identity on duplicate sign-up, and does not leak unstable internal details", async () => {
const { body, response } = await signUp(origin, emailOne, "a totally different password");
expect(response.status).toBe(200);
expect(body.token).toBeNull();
expect(typeof body.user.id).toBe("string");
});
it("keeps exactly one user row for the duplicated email after the duplicate sign-up attempt", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ count: string }>(
admin,
'select count(*)::text as count from "ekklesia_core_user" where email = $1',
[emailOne],
);
expect(rows[0]?.count).toBe("1");
} finally {
await safeEnd(admin);
}
});
it("signs out and invalidates the session", async () => {
const response = await fetch(`${origin}/api/auth/sign-out`, {
body: "{}",
headers: {
"content-type": "application/json",
cookie: sessionCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(200);
expect(setCookieValue(response)).toBeDefined();
});
it("resolves the old cookie as anonymous after sign-out", async () => {
const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie });
expect(response.status).toBe(200);
expect(body).toEqual({ kind: "anonymous" });
});
let secondUserId: string;
it("registers and signs in a second, distinct public account", async () => {
const signUpResult = await signUp(origin, emailTwo, password);
expect(signUpResult.response.status).toBe(200);
const signInResult = await signIn(origin, emailTwo, password);
expect(signInResult.response.status).toBe(200);
expect(signInResult.cookiePair).toBeDefined();
const { body } = await fetchPrincipal(origin, {
cookie: signInResult.cookiePair as string,
});
const record = body as { kind: string; userId: string };
expect(record.kind).toBe("authenticated");
secondUserId = record.userId;
expect(secondUserId).toBeTruthy();
});
it("gives the first and second account identical principal shapes with no elevation", async () => {
const first = await signIn(origin, emailOne, password);
const { body: firstBody } = await fetchPrincipal(origin, {
cookie: first.cookiePair as string,
});
const second = await signIn(origin, emailTwo, password);
const { body: secondBody } = await fetchPrincipal(origin, {
cookie: second.cookiePair as string,
});
expect(Object.keys(firstBody as object).sort()).toEqual(
Object.keys(secondBody as object).sort(),
);
expect((firstBody as { kind: string }).kind).toBe((secondBody as { kind: string }).kind);
});
it("gives neither account owner, role, permission, membership, or volunteer data at the database level", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const columns = await safeQuery<{ column_name: string }>(
admin,
"select column_name from information_schema.columns where table_schema = 'public' and table_name = 'ekklesia_core_user'",
);
const columnNames = columns.map((row) => row.column_name);
for (const forbidden of [
"role",
"is_admin",
"is_owner",
"is_staff",
"is_volunteer",
"is_member",
"permissions",
"grants",
]) {
expect(columnNames).not.toContain(forbidden);
}
} finally {
await safeEnd(admin);
}
});
it("never stores a plaintext password", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ password: string | null }>(
admin,
`select a.password from "ekklesia_core_account" a
inner join "ekklesia_core_user" u on u.id = a.user_id
where u.email = $1`,
[emailOne],
);
const stored = rows[0]?.password;
expect(stored).toBeTruthy();
expect(stored).not.toBe(password);
expect(stored?.length ?? 0).toBeGreaterThan(password.length);
} finally {
await safeEnd(admin);
}
});
it("supports a genuine server-to-server sign-up request that carries no Origin header and no Fetch Metadata headers at all", async () => {
const { status } = await rawPostRequest(origin, "/api/auth/sign-up/email", {
email: "[email protected]",
name: "Integration Test User",
password,
});
expect(status).toBe(200);
});
it("allows a trusted browser origin for the principal route with credentials", async () => {
const response = await fetch(`${origin}/api/v1/principal`, {
headers: { cookie: sessionCookie, origin: dashboardOrigin },
});
expect(response.status).toBe(200);
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
});
it("never returns Access-Control-Allow-Origin for an untrusted browser origin", async () => {
const principalResponse = await fetch(`${origin}/api/v1/principal`, {
headers: { origin: untrustedOrigin },
});
expect(principalResponse.headers.get("access-control-allow-origin")).toBeNull();
});
// Production-equivalent Better Auth origin/CSRF protection (ADR 0012). Explicitly setting
// `advanced: { disableCSRFCheck: false, disableOriginCheck: false }` (`core/src/identity/runtime.ts`)
// removes Better Auth's own `isTest()`-driven default that otherwise silently skips origin/CSRF
// validation under a test runner, so the same behavior verified below is also what production
// enforces.
it("accepts a no-cookie first authentication attempt from a trusted browser origin", async () => {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: emailOne, password }),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
});
it("rejects a no-cookie first authentication attempt from an untrusted browser origin", async () => {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email: emailOne, password }),
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
});
const text = await response.text();
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(text).not.toContain(database.url);
expect(text.toLowerCase()).not.toContain("stack");
});
it("rejects a cookie-bearing state-changing request from an untrusted browser origin, and does not invalidate the session", async () => {
const { cookiePair } = await signIn(origin, emailOne, password);
expect(cookiePair).toBeDefined();
const response = await fetch(`${origin}/api/auth/sign-out`, {
body: "{}",
headers: {
"content-type": "application/json",
cookie: cookiePair as string,
origin: untrustedOrigin,
},
method: "POST",
});
const text = await response.text();
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
expect(text).not.toContain(database.url);
expect(text.toLowerCase()).not.toContain("stack");
// The forged/rejected sign-out must never have reached sign-out logic: the session presented
// alongside it must still be valid.
const { body } = await fetchPrincipal(origin, { cookie: cookiePair as string });
expect(body).toMatchObject({ kind: "authenticated" });
});
it("rejects a 7-character password as too short", async () => {
const { response } = await signUp(origin, "[email protected]", "1234567");
expect(response.status).toBe(400);
});
it("accepts an 8-character password", async () => {
const { response, body } = await signUp(origin, "[email protected]", "12345678");
expect(response.status).toBe(200);
expect(body.user.email).toBe("[email protected]");
});
it("accepts a 128-character password", async () => {
const { response } = await signUp(origin, "[email protected]", "a".repeat(128));
expect(response.status).toBe(200);
});
it("rejects a 129-character password as too long", async () => {
const { response } = await signUp(origin, "[email protected]", "a".repeat(129));
expect(response.status).toBe(400);
});
it("leaves Better Auth's handler unavailable outside /api/auth/*", async () => {
const response = await fetch(`${origin}/sign-in/email`, { method: "POST" });
expect(response.status).toBe(404);
});
it("shuts down cleanly, closing the HTTP listener and the PostgreSQL pool", async () => {
await running.shutdown();
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
});
describe.sequential("live API — startup and migration guarantees", () => {
it("does not create any tables or migration history when started against an unmigrated database", async () => {
const database = await createDisposableDatabase("unmigrated");
try {
const config = createApiRuntimeConfig(
{
databaseUrl: database.url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl],
},
port: 0,
},
{ environment: "production" },
);
const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
const origin = apiOrigin(running);
try {
// A request that touches the (nonexistent) identity tables fails, but must not repair
// or create anything. The exact failure body is not asserted here (Better Auth's
// internal-error shape for this case is not a stable public contract); only that it is
// not a successful sign-up.
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
body: JSON.stringify({
email: "[email protected]",
name: "Integration Test User",
password: "correct horse battery staple",
}),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(response.status).not.toBe(200);
} finally {
await running.shutdown();
}
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ count: string }>(
admin,
"select count(*)::text as count from information_schema.tables where table_schema = 'public'",
);
expect(rows[0]?.count).toBe("0");
} finally {
await safeEnd(admin);
}
} finally {
await dropDatabase(database.name);
}
});
});
describe.sequential("live API — principal infrastructure failure is never downgraded to anonymous", () => {
it("returns a sanitized failure, not a false anonymous 200, when the database becomes unreachable mid-session", async () => {
const database = await createDisposableDatabase("infra_failure");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
const origin = apiOrigin(running);
await signUp(origin, "[email protected]", "correct horse battery staple");
const { cookiePair } = await signIn(
origin,
"[email protected]",
"correct horse battery staple",
);
expect(cookiePair).toBeDefined();
// Force the database itself unreachable out from under the running API pool.
await dropDatabase(database.name);
const { body, response } = await fetchPrincipal(origin, { cookie: cookiePair as string });
expect(response.status).toBe(503);
expect(body).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
await running.shutdown().catch(() => undefined);
});
});
describe.sequential("live API — Better Auth logging never leaks operational detail", () => {
it("emits no raw exception, SQL, connection detail, origin header, or stack trace when a real identity database failure occurs", async () => {
const database = await createDisposableDatabase("logging_leak");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
const origin = apiOrigin(running);
await signUp(origin, "[email protected]", "correct horse battery staple");
// Force the database itself unreachable out from under the running API pool, then drive a
// request through Better Auth's own database-backed handler (not Ekklesia's own principal
// route) so Better Auth's internal logging — configured in `core/src/identity/runtime.ts` —
// is the thing actually being exercised here, not Core's own error handling. Without
// `onAPIError: { throw: true }`, the pinned release's own `better-call` transport dependency
// would catch this internally and print it raw via an unconditional
// `console.error("# SERVER_ERROR: ", error)` that no `logger` configuration can reach; with
// it, the failure instead propagates out of `auth.handler()` and becomes a sanitized
// `IdentityRuntimeError` at `handleAuthRequest`'s own boundary, and nothing is logged at all.
await dropDatabase(database.name);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
try {
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({
email: "[email protected]",
password: "correct horse battery staple",
}),
headers: { "content-type": "application/json", origin: dashboardOrigin },
method: "POST",
});
const body = await response.clone().json();
expect(response.status).not.toBe(200);
expect(body).toEqual({ code: "AUTH_REQUEST_FAILED" });
const emitted = [...errorSpy.mock.calls, ...warnSpy.mock.calls, ...logSpy.mock.calls]
.flat()
.map((value) =>
value instanceof Error ? `${value.message}\n${value.stack ?? ""}` : String(value),
)
.join("\n");
// The primary guarantee: onAPIError.throw routes this exact failure out to Core's own
// sanitized boundary before Better Auth (or its own transport dependency) ever gets a
// chance to log anything about it, so nothing is emitted at all.
expect(emitted).toBe("");
// Defense in depth, kept even though the assertion above is currently sufficient: if a
// future change causes something to be logged after all, it must still never contain any
// of this.
expect(emitted).not.toContain(database.url);
expect(emitted).not.toContain(database.name);
expect(emitted.toLowerCase()).not.toContain("password");
expect(emitted.toLowerCase()).not.toContain("stack");
expect(emitted).not.toMatch(/select|insert|update|delete|drizzle/iu);
expect(emitted).not.toContain(dashboardOrigin);
expect(emitted).not.toContain(identityBaseUrl);
} finally {
errorSpy.mockRestore();
warnSpy.mockRestore();
logSpy.mockRestore();
await running.shutdown().catch(() => undefined);
}
});
});
describe.sequential("live API — platform owner and delegated grant management", () => {
let database: { name: string; url: string };
let running: RunningApi;
let origin: string;
let ownerUserId: string;
let ownerCookie: string;
let managerUserId: string;
let managerCookie: string;
let createdGrantId: string;
const ownerEmail = "[email protected]";
const managerEmail = "[email protected]";
const password = "correct horse battery staple";
// This scenario intentionally proves out the explicit-grant lifecycle (create, list, use,
// revoke) against Core's own grant-management permission rather than a module's — a module
// permission would exercise the exact same route and evaluator code, adding coverage breadth
// without coverage depth. The "live API — installed module permission end-to-end" scenario
// below is where a real installed module's declared permission is proven end-to-end instead.
const testPermission = "core.authorization.grants.manage";
// Well-formed (passes `PermissionId` shape validation) but never registered — used to prove the
// live route rejects it even for a caller who otherwise has full grant-management authority.
const unregisteredPermission = "calendar.events.read";
it("starts a freshly migrated API and signs up the eventual owner and eventual delegated manager", async () => {
database = await createDisposableDatabase("authz_http");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url);
origin = apiOrigin(running);
const ownerSignUp = await signUp(origin, ownerEmail, password);
ownerUserId = ownerSignUp.body.user.id;
const managerSignUp = await signUp(origin, managerEmail, password);
managerUserId = managerSignUp.body.user.id;
const ownerSignIn = await signIn(origin, ownerEmail, password);
expect(ownerSignIn.cookiePair).toBeDefined();
ownerCookie = ownerSignIn.cookiePair as string;
});
it("denies grants access to an anonymous caller", async () => {
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
});
it("denies grants access to an authenticated caller with no owner authority and no grant", async () => {
const managerSignInBefore = await signIn(origin, managerEmail, password);
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: managerSignInBefore.cookiePair as string },
});
expect(response.status).toBe(403);
});
it("bootstraps the owner via Core's real bootstrap operation, then confirms owner authority over the HTTP grants API", async () => {
// Exactly what `pnpm authorization:bootstrap-owner` would do, driven directly against the
// same disposable database the running API already uses — no second process needed to prove
// the HTTP layer honors real platform-owner state.
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
} finally {
await close();
}
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: ownerCookie },
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ grants: [] });
});
it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: "core.authorization.grants.manage",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as { grant: { sourceKind: string; userId: string } };
expect(response.status).toBe(201);
expect(body.grant.sourceKind).toBe("explicit");
expect(body.grant.userId).toBe(managerUserId);
});
it("lets the delegated manager — who is not a platform owner — manage another user's grants", async () => {
const managerSignIn = await signIn(origin, managerEmail, password);
expect(managerSignIn.cookiePair).toBeDefined();
managerCookie = managerSignIn.cookiePair as string;
const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: managerCookie },
});
expect(response.status).toBe(200);
});
it("rejects a well-formed but unregistered permission even from a caller with full grant-management authority, and writes no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const grantCountBefore = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[unregisteredPermission],
);
const auditCountBefore = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
[unregisteredPermission],
);
expect(grantCountBefore[0]?.count).toBe("0");
expect(auditCountBefore[0]?.count).toBe("0");
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: unregisteredPermission,
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: managerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const grantCountAfter = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[unregisteredPermission],
);
const auditCountAfter = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
[unregisteredPermission],
);
expect(grantCountAfter[0]?.count).toBe("0");
expect(auditCountAfter[0]?.count).toBe("0");
} finally {
await safeEnd(admin);
}
});
it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
permission: testPermission,
scope: "calendar-event:1",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: managerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as {
grant: { id: string; sourceKind: string; userId: string };
};
expect(response.status).toBe(201);
expect(body.grant.sourceKind).toBe("explicit");
expect(body.grant.userId).toBe(managerUserId);
createdGrantId = body.grant.id;
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string; actor_id: string | null }>(
admin,
`select action, actor_id from "ekklesia_core_authorization_audit" where grant_id = $1`,
[createdGrantId],
);
expect(rows).toEqual([{ action: "grant.create", actor_id: managerUserId }]);
} finally {
await safeEnd(admin);
}
});
it("lists the explicit grant via HTTP", async () => {
const response = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
headers: { cookie: ownerCookie },
});
const body = (await response.json()) as { grants: Array<{ id: string }> };
expect(response.status).toBe(200);
expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
});
it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({
actor: { type: "system" },
permission: testPermission,
sourceKind: "relationship",
subjectUserId: managerUserId,
}),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(400);
});
it("rejects a mutation request with no trusted Origin header, and never returns a permissive CORS header", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
headers: { "content-type": "application/json", cookie: ownerCookie },
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
it("rejects a mutation request from an untrusted Origin the same way", async () => {
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: testPermission, subjectUserId: managerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: untrustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(response.headers.get("access-control-allow-origin")).toBeNull();
});
it("revokes the explicit grant via HTTP and records the revocation audit entry", async () => {
const response = await fetch(`${origin}/api/v1/grants/${createdGrantId}/revoke`, {
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where grant_id = $1 order by created_at`,
[createdGrantId],
);
expect(rows.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
} finally {
await safeEnd(admin);
}
});
it("never exposes a relationship-derived grant through the generic list, create, or revoke HTTP API", async () => {
// Seeded directly through the store, simulating a module's own business operation — never
// through HTTP, which has no way to express sourceKind at all.
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
let relationshipGrantId: string;
try {
const store = createAuthorizationStore(db);
const relationshipGrant = await store.createGrant({
actor: SYSTEM_ACTOR,
permission: parsePermissionId("roster.volunteer.read"),
sourceKind: "relationship",
sourceRef: "roster:volunteer:99",
userId: parseEkklesiaUserId(managerUserId),
});
relationshipGrantId = relationshipGrant.id;
} finally {
await close();
}
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, {
headers: { cookie: ownerCookie },
});
const listBody = (await listResponse.json()) as { grants: Array<{ id: string }> };
expect(listBody.grants.some((grant) => grant.id === relationshipGrantId)).toBe(false);
const revokeResponse = await fetch(`${origin}/api/v1/grants/${relationshipGrantId}/revoke`, {
headers: { cookie: ownerCookie, origin: dashboardOrigin },
method: "POST",
});
expect(revokeResponse.status).toBe(404);
expect(await revokeResponse.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
});
it("shuts down cleanly, closing the one shared pool", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
await dropDatabase(database.name);
});
});
describe.sequential("live API — authorization infrastructure failure is never downgraded to a false denial", () => {
it("returns a sanitized 503, not a 403, when the database becomes unreachable mid-session", async () => {
const database = await createDisposableDatabase("authz_infra_failure");
await applyCoreMigrations(database.url);
const running = await startMigratedApi(database.url);
const origin = apiOrigin(running);
await signUp(origin, "[email protected]", "correct horse battery staple");
const { cookiePair } = await signIn(
origin,
"[email protected]",
"correct horse battery staple",
);
expect(cookiePair).toBeDefined();
// Force the database itself unreachable out from under the running API pool — the same
// technique the identity infra-failure test above uses, applied to a grants route this time.
await dropDatabase(database.name);
const response = await fetch(`${origin}/api/v1/grants?userId=does-not-matter`, {
headers: { cookie: cookiePair as string },
});
expect(response.status).toBe(503);
const body = (await response.json()) as { code: string };
expect(["PRINCIPAL_RESOLUTION_FAILED", "AUTHORIZATION_EVALUATION_FAILED"]).toContain(body.code);
await running.shutdown().catch(() => undefined);
});
});
describe.sequential("live API — installed module permission end-to-end", () => {
let database: { name: string; url: string };
let modulesDirectory: string;
let running: RunningApi;
let origin: string;
let ownerUserId: string;
let ownerCookie: string;
let createdGrantId: string;
const ownerEmail = "[email protected]";
const password = "correct horse battery staple";
const modulePermission = "calendar.events.read";
const absentPermission = "scheduler.jobs.read";
it("recognizes a real installed module and passes permission composition in Doctor, without executing its server entrypoint", async () => {
modulesDirectory = await createFixtureModulesDirectory();
await createFixtureModule(modulesDirectory, "calendar", {
permissions: [
{
baseline: "restricted",
description: "Read calendar events.",
id: modulePermission,
},
],
});
const report = await diagnoseEkklesia({
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
},
runtimeRange: ">=0.0.0",
runtimeVersion: process.versions.node,
});
// Only the module-discovery and permission-composition checks are asserted here — this
// fixture's temporary directory has no `core/drizzle` sibling for migrations discovery to
// find, which is unrelated to what this scenario proves and would otherwise make the check
// fail for a reason that has nothing to do with permission composition.
const moduleCheck = report.checks.find((entry) => entry.id === "modules.discovery");
const permissionsCheck = report.checks.find((entry) => entry.id === "modules.permissions");
expect(moduleCheck).toMatchObject({ status: "pass" });
expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]);
expect(permissionsCheck).toMatchObject({
details: ["2 permissions registered"],
status: "pass",
});
});
it("starts a freshly migrated API that discovers the fixture module and signs up its eventual owner", async () => {
database = await createDisposableDatabase("authz_module");
await applyCoreMigrations(database.url);
running = await startMigratedApi(database.url, modulesDirectory);
origin = apiOrigin(running);
const ownerSignUp = await signUp(origin, ownerEmail, password);
ownerUserId = ownerSignUp.body.user.id;
const ownerSignIn = await signIn(origin, ownerEmail, password);
expect(ownerSignIn.cookiePair).toBeDefined();
ownerCookie = ownerSignIn.cookiePair as string;
// Existing identity HTTP behavior — sign-up, sign-in, principal resolution — is unaffected by
// module permission composition running before it.
const { body: principal } = await fetchPrincipal(origin, { cookie: ownerCookie });
expect(principal).toEqual({ kind: "authenticated", userId: ownerUserId });
});
it("lets an authorized platform owner create an explicit grant for the installed module's permission, with an atomic audit entry using its declared id", async () => {
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) });
} finally {
await close();
}
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
const body = (await response.json()) as {
grant: { id: string; permission: string; sourceKind: string };
};
expect(response.status).toBe(201);
expect(body.grant.permission).toBe(modulePermission);
expect(body.grant.sourceKind).toBe("explicit");
createdGrantId = body.grant.id;
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ action: string; permission_id: string }>(
admin,
`select action, permission_id from "ekklesia_core_authorization_audit" where grant_id = $1`,
[createdGrantId],
);
expect(rows).toEqual([{ action: "grant.create", permission_id: modulePermission }]);
} finally {
await safeEnd(admin);
}
});
it("rejects a well-formed permission absent from the composed registry, writing no grant or audit row", async () => {
const admin = await openAdministrativeClient(database.url);
try {
const before = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[absentPermission],
);
expect(before[0]?.count).toBe("0");
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: absentPermission, subjectUserId: ownerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const after = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_grant" where permission_id = $1`,
[absentPermission],
);
const audit = await safeQuery<{ count: string }>(
admin,
`select count(*)::text as count from "ekklesia_core_authorization_audit" where permission_id = $1`,
[absentPermission],
);
expect(after[0]?.count).toBe("0");
expect(audit[0]?.count).toBe("0");
} finally {
await safeEnd(admin);
}
});
it("shuts down cleanly, then restarting with the module absent rejects new grants for its former permission while the historical grant remains listable", async () => {
await running.shutdown();
const remainingConnections = await activeConnectionCount(database.name);
expect(remainingConnections).toBe(0);
const emptyModulesDirectory = await createFixtureModulesDirectory();
running = await startMigratedApi(database.url, emptyModulesDirectory);
origin = apiOrigin(running);
// The owner's existing session survives the restart (sessions live in the database, not the
// process), so the same cookie proves ordinary identity behavior is unaffected here too.
const listResponse = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, {
headers: { cookie: ownerCookie },
});
const listBody = (await listResponse.json()) as {
grants: Array<{ id: string; permission: string }>;
};
expect(listResponse.status).toBe(200);
expect(listBody.grants.some((grant) => grant.id === createdGrantId)).toBe(true);
const response = await fetch(`${origin}/api/v1/grants`, {
body: JSON.stringify({ permission: modulePermission, subjectUserId: ownerUserId }),
headers: {
"content-type": "application/json",
cookie: ownerCookie,
origin: dashboardOrigin,
},
method: "POST",
});
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
const admin = await openAdministrativeClient(database.url);
try {
const rows = await safeQuery<{ id: string }>(
admin,
`select id from "ekklesia_core_grant" where id = $1 and revoked_at is null`,
[createdGrantId],
);
expect(rows).toHaveLength(1);
} finally {
await safeEnd(admin);
}
await running.shutdown();
await dropDatabase(database.name);
});
});