feat: add authorization bootstrap and HTTP enforcement
Wires Core's identity and authorization domains into apps/api over one shared, validated PostgreSQL pool; adds an explicit, single-use, concurrency-safe platform-owner bootstrap CLI; adds a smallest-possible immutable permission registry with one Core-owned restricted permission (core.authorization.grants.manage); and adds reusable Hono authorization enforcement plus a narrowly scoped /api/v1/grants HTTP API for managing explicit grants only, gated by an exact trusted Origin on every mutation.
This commit is contained in:
@@ -2,6 +2,12 @@ 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,
|
||||
@@ -812,3 +818,278 @@ describe.sequential("live API — Better Auth logging never leaks operational de
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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";
|
||||
const testPermission = "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("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);
|
||||
});
|
||||
});
|
||||
|
||||
+122
-19
@@ -1,8 +1,9 @@
|
||||
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||
import type { AuthorizationEvaluator, AuthorizationStore } from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createApiApp } from "../src/app.js";
|
||||
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
@@ -15,10 +16,37 @@ function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): Ekklesia
|
||||
};
|
||||
}
|
||||
|
||||
/** A fully stubbed authorization dependency, denying everything by default — tests that need a
|
||||
* specific decision or store behavior override the relevant method explicitly. */
|
||||
function fakeAuthorization(overrides: {
|
||||
readonly evaluator?: Partial<AuthorizationEvaluator>;
|
||||
readonly store?: Partial<AuthorizationStore>;
|
||||
}): CreateApiAppAuthorizationOptions {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
...overrides.evaluator,
|
||||
};
|
||||
const store: AuthorizationStore = {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
...overrides.store,
|
||||
};
|
||||
return { evaluator, store };
|
||||
}
|
||||
|
||||
describe("Ekklesia API Hono application", () => {
|
||||
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
|
||||
@@ -29,7 +57,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
|
||||
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/sign-in/email", { method: "POST" });
|
||||
|
||||
@@ -41,7 +73,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
});
|
||||
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal");
|
||||
|
||||
@@ -55,7 +91,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
|
||||
});
|
||||
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal");
|
||||
const body = (await response.json()) as Record<string, unknown>;
|
||||
@@ -71,7 +111,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
.fn()
|
||||
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
|
||||
});
|
||||
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal");
|
||||
|
||||
@@ -84,7 +128,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
const runtime = fakeRuntime({
|
||||
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
|
||||
});
|
||||
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||
const body = await response.json();
|
||||
@@ -96,7 +144,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/does-not-exist");
|
||||
|
||||
@@ -105,7 +157,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
|
||||
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: { origin: trustedOrigin },
|
||||
@@ -116,7 +172,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
|
||||
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: { origin: untrustedOrigin },
|
||||
@@ -126,7 +186,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
|
||||
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal");
|
||||
|
||||
@@ -134,7 +198,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
|
||||
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: {
|
||||
@@ -149,7 +217,11 @@ describe("Ekklesia API Hono application", () => {
|
||||
});
|
||||
|
||||
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/principal", {
|
||||
headers: { origin: trustedOrigin },
|
||||
@@ -158,10 +230,41 @@ describe("Ekklesia API Hono application", () => {
|
||||
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
|
||||
});
|
||||
|
||||
// "No generalized session/user object stored in Hono context" is verified by construction
|
||||
// rather than by a black-box context probe: unlike Better Auth's own documented Hono example
|
||||
// (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls
|
||||
// `c.set` at all — the only per-request identity data that ever exists is the narrow
|
||||
// EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact
|
||||
// shape (kind + userId only) is already asserted by the principal-shape tests in this file.
|
||||
// Unlike Better Auth's own documented Hono example (which hydrates `c.set("user", session.user)`
|
||||
// on every request), createApiApp only ever calls `c.set` once, for the narrow EkklesiaPrincipal
|
||||
// (kind + userId only), and only on routes `requirePermission` actually protects — proven by the
|
||||
// grants-routes tests, not by a black-box context probe here. `/api/v1/principal` itself still
|
||||
// never touches `c.set`; its response shape is asserted directly above.
|
||||
|
||||
describe("grants routes mounting (see grants-routes.test.ts for full coverage)", () => {
|
||||
it("mounts the v1 grants routes under /api/v1/grants", async () => {
|
||||
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: false });
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants?userId=user_123");
|
||||
|
||||
// Reaching the permission check (and being denied by it) proves the route is mounted and
|
||||
// wired to the injected evaluator, rather than falling through to the 404 handler.
|
||||
expect(response.status).toBe(403);
|
||||
expect(evaluatorAuthorize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies the same CORS policy to /api/v1/grants as the rest of /api/v1/*", async () => {
|
||||
const app = createApiApp({
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime: fakeRuntime(),
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants?userId=user_123", {
|
||||
headers: { origin: untrustedOrigin },
|
||||
});
|
||||
|
||||
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
ANONYMOUS_PRINCIPAL,
|
||||
createAuthenticatedPrincipal,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
createPermissionRegistry,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type AuthorizationMiddlewareOptions,
|
||||
getAuthorizedPrincipal,
|
||||
requirePermission,
|
||||
requireTrustedOrigin,
|
||||
} from "../src/authorization-middleware.js";
|
||||
|
||||
const testPermission = parsePermissionId("core.test.restricted-thing");
|
||||
const unregisteredPermission = parsePermissionId("core.test.unregistered-thing");
|
||||
const registry = createPermissionRegistry([
|
||||
{ baseline: "restricted", description: "A restricted test permission.", id: testPermission },
|
||||
]);
|
||||
|
||||
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeEvaluator(overrides: Partial<AuthorizationEvaluator> = {}): AuthorizationEvaluator {
|
||||
return {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTestApp(options: Partial<AuthorizationMiddlewareOptions> = {}) {
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const evaluator = options.evaluator ?? fakeEvaluator();
|
||||
const middlewareOptions: AuthorizationMiddlewareOptions = { evaluator, registry, runtime };
|
||||
|
||||
const app = new Hono();
|
||||
app.get("/protected", requirePermission(middlewareOptions, testPermission), (c) => {
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
return c.json({ principalKind: principal.kind });
|
||||
});
|
||||
app.get("/unregistered", requirePermission(middlewareOptions, unregisteredPermission), (c) =>
|
||||
c.json({ ok: true }),
|
||||
);
|
||||
app.get("/unprotected", (c) => {
|
||||
let threw = false;
|
||||
try {
|
||||
getAuthorizedPrincipal(c);
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
return c.json({ threw });
|
||||
});
|
||||
|
||||
return { app, evaluator, runtime };
|
||||
}
|
||||
|
||||
describe("requirePermission", () => {
|
||||
it("allows the request through and stores the resolved principal on context when the decision is allowed", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
||||
});
|
||||
const { app } = buildTestApp({ runtime });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ principalKind: "authenticated" });
|
||||
});
|
||||
|
||||
it("returns a sanitized 403 when the evaluator denies", async () => {
|
||||
const evaluator = fakeEvaluator({ authorize: vi.fn().mockResolvedValue({ allowed: false }) });
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
it("returns a sanitized 403 for an unregistered permission without ever resolving the principal", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const { app } = buildTestApp({ runtime });
|
||||
|
||||
const response = await app.request("/unregistered");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
||||
expect(runtime.resolvePrincipal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a sanitized 503, not a 403, when principal resolution fails", async () => {
|
||||
const runtime = fakeRuntime({
|
||||
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
||||
});
|
||||
const { app } = buildTestApp({ runtime });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
||||
});
|
||||
|
||||
it("returns a sanitized 503, not a 403, when evaluation itself fails", async () => {
|
||||
const evaluator = fakeEvaluator({
|
||||
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
||||
});
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_EVALUATION_FAILED" });
|
||||
});
|
||||
|
||||
it("never leaks a raw infrastructure error message through either sanitized 503", async () => {
|
||||
const hostileMessage = "password=hunter2 host=db.internal.example port=5432";
|
||||
const evaluator = fakeEvaluator({
|
||||
authorize: vi.fn().mockRejectedValue(new Error(hostileMessage)),
|
||||
});
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
const response = await app.request("/protected");
|
||||
const text = await response.text();
|
||||
|
||||
expect(text).not.toContain("hunter2");
|
||||
expect(text).not.toContain("db.internal.example");
|
||||
});
|
||||
|
||||
it("passes the registry's own baseline and permission id to the evaluator, never a client-supplied value", async () => {
|
||||
const evaluator = fakeEvaluator();
|
||||
const { app } = buildTestApp({ evaluator });
|
||||
|
||||
// A hostile client cannot even express "use this baseline instead" — there is no request
|
||||
// field the middleware reads for it — but sending an unrelated header proves it is ignored.
|
||||
await app.request("/protected", { headers: { "x-baseline": "public" } });
|
||||
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseline: "restricted", permission: testPermission }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthorizedPrincipal", () => {
|
||||
it("throws when called on a route requirePermission never protected", async () => {
|
||||
const { app } = buildTestApp();
|
||||
|
||||
const response = await app.request("/unprotected");
|
||||
|
||||
expect(await response.json()).toEqual({ threw: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireTrustedOrigin", () => {
|
||||
const trustedOrigins = new Set(["https://dashboard.example.test"]);
|
||||
|
||||
function buildOriginApp() {
|
||||
const app = new Hono();
|
||||
app.post("/mutate", requireTrustedOrigin(trustedOrigins), (c) => c.json({ ok: true }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("allows a request from a trusted origin", async () => {
|
||||
const app = buildOriginApp();
|
||||
|
||||
const response = await app.request("/mutate", {
|
||||
headers: { origin: "https://dashboard.example.test" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("rejects a request with no Origin header at all", async () => {
|
||||
const app = buildOriginApp();
|
||||
|
||||
const response = await app.request("/mutate", { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("rejects a request from an untrusted origin", async () => {
|
||||
const app = buildOriginApp();
|
||||
|
||||
const response = await app.request("/mutate", {
|
||||
headers: { origin: "https://evil.example.test" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import {
|
||||
createAuthenticatedPrincipal,
|
||||
parseAuthorizationScope,
|
||||
parseEkklesiaUserId,
|
||||
parseGrantId,
|
||||
parsePermissionId,
|
||||
} from "@ekklesia/contracts";
|
||||
import {
|
||||
type AuthorizationEvaluator,
|
||||
type AuthorizationStore,
|
||||
AuthorizationStoreError,
|
||||
CORE_PERMISSION_REGISTRY,
|
||||
} from "@ekklesia/core/authorization";
|
||||
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||
import { Hono } from "hono";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createGrantsRoutes } from "../src/grants-routes.js";
|
||||
|
||||
const trustedOrigin = "https://dashboard.example.test";
|
||||
const untrustedOrigin = "https://evil.example.test";
|
||||
const manager = createAuthenticatedPrincipal("manager_1");
|
||||
const subjectUserId = parseEkklesiaUserId("subject_1");
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
const grantId = parseGrantId("11111111-1111-1111-1111-111111111111");
|
||||
|
||||
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
||||
return {
|
||||
handleAuthRequest: vi.fn(),
|
||||
resolvePrincipal: vi.fn().mockResolvedValue(manager),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationStore {
|
||||
return {
|
||||
createGrant: vi.fn(),
|
||||
grantPlatformOwner: vi.fn(),
|
||||
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
||||
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
||||
listGrantsForUser: vi.fn().mockResolvedValue([]),
|
||||
revokeGrant: vi.fn(),
|
||||
revokePlatformOwner: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(options: {
|
||||
readonly allowed?: boolean;
|
||||
readonly runtime?: EkklesiaIdentityRuntime;
|
||||
readonly store?: AuthorizationStore;
|
||||
}) {
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: options.allowed ?? true }),
|
||||
};
|
||||
const runtime = options.runtime ?? fakeRuntime();
|
||||
const store = options.store ?? fakeStore();
|
||||
const app = new Hono();
|
||||
app.route(
|
||||
"/",
|
||||
createGrantsRoutes({
|
||||
evaluator,
|
||||
registry: CORE_PERMISSION_REGISTRY,
|
||||
runtime,
|
||||
store,
|
||||
trustedOrigins: new Set([trustedOrigin]),
|
||||
}),
|
||||
);
|
||||
return { app, evaluator, runtime, store };
|
||||
}
|
||||
|
||||
describe("GET /api/v1/grants", () => {
|
||||
it("denies an anonymous or non-manager authenticated caller (evaluator says no)", async () => {
|
||||
const { app, evaluator } = buildApp({ allowed: false });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(evaluator.authorize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseline: "restricted" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a caller the evaluator authorizes — whether by platform ownership or a delegated grants.manage grant, the route cannot tell and does not need to (see core/tests/authorization-evaluator.test.ts for that distinction)", async () => {
|
||||
const grants = [
|
||||
{
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
id: grantId,
|
||||
permission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
},
|
||||
];
|
||||
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
const body = (await response.json()) as { grants: unknown[] };
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(body.grants).toHaveLength(1);
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" });
|
||||
});
|
||||
|
||||
it("rejects a missing or malformed userId query parameter", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants?userId=");
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_LIST_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/grants", () => {
|
||||
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
||||
const { app, evaluator } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an untrusted Origin header the same way", async () => {
|
||||
const { app } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
});
|
||||
|
||||
it("denies a caller the evaluator does not authorize", async () => {
|
||||
const { app, store } = buildApp({ allowed: false });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates an explicit grant, deriving sourceKind and actor server-side from the authenticated principal, never from the request body", async () => {
|
||||
const createdGrant = {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
id: grantId,
|
||||
permission,
|
||||
scope: parseAuthorizationScope("calendar-event:123"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, scope: "calendar-event:123", subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
const body = (await response.json()) as { grant: { id: string } };
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(body.grant.id).toBe(grantId);
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
permission,
|
||||
scope: "calendar-event:123",
|
||||
sourceKind: "explicit",
|
||||
userId: subjectUserId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"actor",
|
||||
"owner",
|
||||
"isOwner",
|
||||
"sourceKind",
|
||||
"sourceRef",
|
||||
"id",
|
||||
"grantId",
|
||||
"createdAt",
|
||||
"revokedAt",
|
||||
"createdByActorId",
|
||||
"effectivePermissions",
|
||||
])("rejects a request body carrying a disallowed field (%s), even alongside otherwise-valid fields", async (field) => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ [field]: "hostile-value", permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a malformed permission, subject id, scope, or expiresAt", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const responses = await Promise.all(
|
||||
[
|
||||
{ permission: "not-namespaced", subjectUserId },
|
||||
{ permission, subjectUserId: "" },
|
||||
{ permission, scope: "", subjectUserId },
|
||||
{ expiresAt: "not-a-date", permission, subjectUserId },
|
||||
].map((body) =>
|
||||
app.request("/api/v1/grants", {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(400);
|
||||
}
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
createGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_CREATE_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify({ permission, subjectUserId }),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_CREATE_FAILED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/grants/:grantId/revoke", () => {
|
||||
it("rejects a request with no trusted Origin header before ever checking permission", async () => {
|
||||
const { app, evaluator } = buildApp({});
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, { method: "POST" });
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes an explicit grant, deriving the actor server-side from the authenticated principal", async () => {
|
||||
const store = fakeStore({ revokeGrant: vi.fn().mockResolvedValue(undefined) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ success: true });
|
||||
expect(store.revokeGrant).toHaveBeenCalledWith({
|
||||
actor: { type: "user", userId: manager.userId },
|
||||
grantId,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
it("always constrains revocation to sourceKind explicit, so a relationship-derived grant can never be targeted by id", async () => {
|
||||
// The store itself enforces this by treating a sourceKind mismatch exactly like a missing
|
||||
// grant; this test proves the route always asks for that constraint, and correctly surfaces
|
||||
// the resulting "not found" as 404 rather than leaking that the grant exists but is the wrong
|
||||
// kind.
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
expect(store.revokeGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sourceKind: "explicit" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a malformed grant id", async () => {
|
||||
const { app, store } = buildApp({});
|
||||
|
||||
const response = await app.request("/api/v1/grants/%20/revoke", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps a store infrastructure failure to a sanitized 503", async () => {
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_REVOKE_FAILED")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -176,6 +176,26 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("constructs exactly one physical PostgreSQL pool for both the identity and authorization views, and closes only that one pool on shutdown", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
const running = await startApi(validConfig());
|
||||
|
||||
// Identity (via Better Auth's Drizzle adapter) and authorization both read from the same
|
||||
// running API, yet only one `new Pool(...)` call is ever made — proving the shared-pool
|
||||
// composition in apps/api/src/database.ts never falls back to a second, independent pool.
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
|
||||
await running.shutdown();
|
||||
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps shutdown idempotent for calls made after the first has already resolved", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
@@ -205,6 +225,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
code: "IDENTITY_CONSTRUCTION_FAILED",
|
||||
});
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -219,6 +240,7 @@ describe("Ekklesia API shutdown lifecycle", () => {
|
||||
|
||||
await expect(startApi(validConfig())).rejects.toThrow();
|
||||
|
||||
expect(poolConstructor).toHaveBeenCalledTimes(1);
|
||||
expect(poolEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user