Files
Ekklesia/apps/api/tests/grants-routes.test.ts
T
ekklesiaandClaude Sonnet 5 d3c6bd73d3 fix: enforce API request limits
Add a single platform-owned request-body policy (hono/body-limit) with
small, named limits for Better Auth and Core's grant-mutation endpoint, and
a new required maxBodyBytes field on the executable module route contract so
every module route makes a conscious no-body-or-bounded-body choice, capped
at a platform ceiling. Every oversized request gets one sanitized 413
contract and never reaches business logic, authorization, or a handler.
Also configure finite Node header/request/keep-alive timeouts, including the
non-obvious connectionsCheckingInterval needed for them to take effect
promptly, so a slow-header or slow-body connection cannot hold a socket
indefinitely.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 12:29:24 -05:00

515 lines
18 KiB
TypeScript

import {
createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId,
parseGrantId,
parsePermissionId,
} from "@ekklesia/contracts";
import {
type AuthorizationEvaluator,
type AuthorizationStore,
AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE,
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 { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
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");
// Well-formed but never declared in `CORE_PERMISSION_REGISTRY` — used to prove the routes reject
// (or, for listing, still return) an unregistered permission id rather than assuming registration.
const permission = parsePermissionId("calendar.events.read");
// A permission `CORE_PERMISSION_REGISTRY` actually declares, so tests that must reach
// `store.createGrant`/`store.revokeGrant` can do so. This suite exercises `createGrantsRoutes` in
// isolation and intentionally injects `CORE_PERMISSION_REGISTRY` directly rather than a composed
// platform registry — the routes have no notion of "Core" versus "module" permissions, they only
// ever consult whichever registry they are given. Coverage proving a module-declared permission
// works through the exact same route code lives in
// `apps/api/tests/authorization-middleware.test.ts` (unit) and the "installed module permission
// end-to-end" scenario in `apps/api/tests/api-http.integration.ts` (live).
const registeredPermission = CORE_AUTHORIZATION_GRANTS_MANAGE;
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: registeredPermission,
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: registeredPermission,
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: registeredPermission,
scope: "calendar-event:123",
sourceKind: "explicit",
userId: subjectUserId,
}),
);
});
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
const { app, store } = buildApp({});
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(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
expect(store.createGrant).not.toHaveBeenCalled();
});
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: registeredPermission, 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" });
});
});
describe("Core grant mutation request body limit", () => {
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
const overResponse = await app.request("/api/v1/grants", {
body: overSizedBody,
headers: {
"content-length": String(overSizedBody.length),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(overResponse.status).toBe(413);
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
const underResponse = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(underResponse.status).not.toBe(413);
});
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
const store = fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
registry: CORE_PERMISSION_REGISTRY,
runtime: fakeRuntime(),
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
const response = await app.request("/api/v1/grants", {
body: "a",
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(evaluatorAuthorize).not.toHaveBeenCalled();
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > 3) {
controller.close();
return;
}
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("also bounds the revoke mutation body", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
});