Wires composePlatformPermissionRegistry into apps/api's production startup: startApi() discovers every installed module, composes the one immutable platform permission registry (Core's own declarations plus every module's), and only then constructs the shared PostgreSQL pool, Better Auth, and the HTTP server. A discovery or composition failure — an invalid manifest, a reserved core.* namespace claim, or a duplicate declaration — propagates before anything exists to close; there is no Core-only registry fallback. apps/api/src/platform-permissions.ts resolves the repository root from its own file location, never process.cwd(), so package-scoped test runs discover the right modules/ directory. createApiApp/createGrantsRoutes now require the composed registry as an injected authorization option, replacing the previous hard-coded CORE_PERMISSION_REGISTRY; requirePermission continues to work identically for Core-owned and module-owned permissions, since it has no concept of the distinction. Extends pnpm doctor with a modules.permissions check (reusing the already-discovered module list, distinguishing reserved-namespace from generic-duplicate failures, never re-discovering modules) and pnpm modules:validate to compose the same registry before a module is considered installable. Adds focused coverage: startup-ordering and failure-isolation tests in apps/api/tests/lifecycle.test.ts, module-owned-declaration enforcement tests in apps/api/tests/authorization-middleware.test.ts, Doctor composition tests in core/tests/doctor.test.ts, and a live PostgreSQL end-to-end scenario proving a real installed module's permission is discoverable, grantable, and deniable once the module is absent again after a restart, without ever executing its server entrypoint.
1404 lines
53 KiB
TypeScript
1404 lines
53 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";
|
|
// Module permission registration does not exist yet, so `core.authorization.grants.manage` —
|
|
// the one permission `CORE_PERMISSION_REGISTRY` actually declares — stands in for "some
|
|
// registered permission" throughout this scenario's explicit-grant lifecycle.
|
|
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);
|
|
});
|
|
});
|