Two related fail-closed gaps let a well-formed but never-declared permission id slip through: - POST /api/v1/grants created explicit grants for any well-formed PermissionId without checking the injected permission registry, allowing dormant grants for invented identifiers that could become effective the moment those identifiers were registered later. - createAuthorizationEvaluator validated the permission id only inside the restricted-baseline branch, so a malformed permission combined with the public or authenticated baseline could still return allowed. Both now fail closed: the grants route checks the target permission against the trusted registry before calling store.createGrant (sanitized 403, no store or audit write), and the evaluator validates permission shape before any baseline is allowed to return an allowed decision, without adding a registry dependency to the evaluator itself. The live integration lifecycle test switched from an unregistered calendar.events.read to the registered core.authorization.grants.manage permission, since module permission registration does not exist yet.
1150 lines
44 KiB
TypeScript
1150 lines
44 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 {
|
|
applyModuleMigrations,
|
|
createPostgresMigrationDatabase,
|
|
discoverAllMigrations,
|
|
} from "@ekklesia/core/migrations";
|
|
import { mkdtemp, rm } 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 });
|
|
}
|
|
}
|
|
|
|
afterAll(async () => {
|
|
for (const databaseName of [...trackedDatabases]) {
|
|
await dropDatabase(databaseName).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): 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" },
|
|
);
|
|
// A narrow, explicit test-only override: production composition (server.ts's `main`) never
|
|
// sets this. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would
|
|
// otherwise reject this suite's many sequential requests.
|
|
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|