Explicit-grant management could produce states an operator could not reason about, and pushed request-shaped problems into the database. Two identical createGrant calls produced two grants; five concurrent identical calls produced five. Revoking one returned success while the equivalent kept authorizing the same user — an operator could believe they had removed access that was still live. Two issuances are now the same authorization source when user_id, permission_id, scope, source_kind, and source_ref are all equal, with NULL equal only to NULL; expiry, creation metadata, actor, and grant id do not distinguish them. Creating one while an equivalent is currently effective fails with AUTHORIZATION_GRANT_ALREADY_EXISTS (HTTP 409, no-store) naming nothing about the grant that already exists, and writing no grant and no audit row. A revoked or expired equivalent does not block a fresh issuance. Conflict rather than idempotent return: the caller asked to record a new authorization with their own actor and expiry, and answering "done" while recording neither would hide that nothing happened. Enforcement is database-backed, since nothing constrains the number of API processes: every create and revoke touching one equivalence tuple takes a transaction-scoped advisory lock keyed by a hash of that tuple, encoded as a JSON array so NULL stays distinct from the empty string and a crafted scope cannot forge a separator. A collision can only make unrelated tuples serialize, never hide a conflict, because the decision is always an exact SQL predicate. Its single-key lock space is verified disjoint from the two-key space the migration runner and bootstrap use. A time-dependent partial unique index was rejected: now() is not immutable and cannot be indexed. One revocation now clears every unrevoked equivalent — including expired-but- unrevoked legacy rows, so the history is left in one state — with one audit row per grant actually revoked, each carrying its own id, in one transaction. Differently scoped or sourced grants are untouched. Historical duplicates are not silently revoked by migration; the store prevents new effective equivalents and an operator's explicit revocation resolves the class. The request boundary no longer leaks into PostgreSQL. GrantId accepts exactly the canonical lowercase hyphenated UUID the database emits, with no trimming and no case folding, so a malformed path parameter is a 400 decided after authorization but before any store call instead of reaching the UUID comparison and returning an indistinguishable 503; a canonical unknown id still returns 404. expiresAt accepts exactly YYYY-MM-DDTHH:mm:ss.sssZ, byte-for-byte equal to the parsed date's own toISOString, and must be strictly future — bare new Date silently rewrote 2026-02-31 to March 3rd and accepted locale, date-only, offset, and millisecond-free forms, and a past expiry was stored as a grant dead on arrival. The store validates the same thing before any SQL so trusted internal callers get the guarantee too. The generic HTTP surface now rejects a scope field in every form, including null. No route middleware supplies a scope to the evaluator, so a scope accepted here produced a grant that persisted, listed, and authorized nothing. Core's scope semantics are unchanged for internal callers; no resolver was added. Listing was unbounded and returned a user's entire history. It is now keyset pagination over created_at DESC, id DESC with a strict boundary on both values, so rows sharing a timestamp are neither skipped nor repeated, at most limit + 1 rows read, explicit-source filtering server-side on every page, and an opaque unsigned position cursor that is strictly decoded and reveals nothing on rejection. Core's list method requires a validated finite bound with no unbounded fallback anywhere. Co-Authored-By: Claude Opus 5 <[email protected]>
1457 lines
51 KiB
TypeScript
1457 lines
51 KiB
TypeScript
import {
|
|
ANONYMOUS_PRINCIPAL,
|
|
createAuthenticatedPrincipal,
|
|
type ModuleDatabase,
|
|
type ModuleRouteHandler,
|
|
type ModuleRouteHandlerContext,
|
|
type ModuleRuntimeServices,
|
|
parsePermissionId,
|
|
type PermissionId,
|
|
type RegisteredModuleHttpRoute,
|
|
} from "@ekklesia/contracts";
|
|
import {
|
|
type AuthorizationEvaluator,
|
|
type AuthorizationStore,
|
|
createPermissionRegistry,
|
|
type PermissionRegistry,
|
|
} from "@ekklesia/core/authorization";
|
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createApiApp } from "../src/app.js";
|
|
import {
|
|
canonicalizeVaryHeader,
|
|
mergedVaryHeader,
|
|
ModuleRouteConfigurationError,
|
|
} from "../src/module-http-routes.js";
|
|
|
|
const trustedOrigin = "https://dashboard.example.test";
|
|
const untrustedOrigin = "https://evil.example.test";
|
|
const readPermission = parsePermissionId("calendar.events.read");
|
|
const writePermission = parsePermissionId("calendar.events.write");
|
|
|
|
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
|
return {
|
|
handleAuthRequest: vi.fn(),
|
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function fakeStore(): AuthorizationStore {
|
|
return {
|
|
createGrant: vi.fn(),
|
|
grantPlatformOwner: vi.fn(),
|
|
hasActiveGrant: vi.fn().mockResolvedValue(false),
|
|
isPlatformOwner: vi.fn().mockResolvedValue(false),
|
|
listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
|
|
revokeGrant: vi.fn(),
|
|
revokePlatformOwner: vi.fn(),
|
|
};
|
|
}
|
|
|
|
/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
|
|
* body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
|
|
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
|
|
* suite further down overrides it explicitly to prove the contract itself. */
|
|
function moduleRoute(
|
|
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
|
|
): RegisteredModuleHttpRoute {
|
|
return Object.freeze({ maxBodyBytes: 1024, ...fields });
|
|
}
|
|
|
|
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
|
|
* authorization, and response sanitization, never real database behavior, so no PostgreSQL is
|
|
* required. Each `database` method stays a distinct spy so a test can assert none of them were
|
|
* ever called (denied, trusted-origin-rejected, and `OPTIONS` requests never reach a handler). */
|
|
function fakeModuleServices(): ModuleRuntimeServices {
|
|
const database = Object.freeze({
|
|
delete: vi.fn(),
|
|
execute: vi.fn(),
|
|
insert: vi.fn(),
|
|
select: vi.fn(),
|
|
selectDistinct: vi.fn(),
|
|
selectDistinctOn: vi.fn(),
|
|
transaction: vi.fn(),
|
|
update: vi.fn(),
|
|
}) as unknown as ModuleDatabase;
|
|
return Object.freeze({ database });
|
|
}
|
|
|
|
function expectDatabaseFakeUntouched(services: ModuleRuntimeServices): void {
|
|
expect(vi.mocked(services.database.select)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.selectDistinct)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.selectDistinctOn)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.insert)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.update)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.delete)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.execute)).not.toHaveBeenCalled();
|
|
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
|
|
}
|
|
|
|
function buildModuleApp(options: {
|
|
readonly evaluator?: AuthorizationEvaluator;
|
|
readonly registry: PermissionRegistry;
|
|
readonly routes: readonly RegisteredModuleHttpRoute[];
|
|
readonly runtime?: EkklesiaIdentityRuntime;
|
|
readonly services?: ModuleRuntimeServices;
|
|
readonly trustedOrigins?: readonly string[];
|
|
}) {
|
|
const evaluator = options.evaluator ?? {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const runtime = options.runtime ?? fakeRuntime();
|
|
const services = options.services ?? fakeModuleServices();
|
|
const app = createApiApp({
|
|
authorization: { evaluator, registry: options.registry, store: fakeStore() },
|
|
moduleRoutes: options.routes,
|
|
moduleServices: services,
|
|
runtime,
|
|
trustedOrigins: options.trustedOrigins ?? [trustedOrigin],
|
|
});
|
|
return { app, evaluator, runtime, services };
|
|
}
|
|
|
|
function permissionRegistry(
|
|
declarations: ReadonlyArray<{
|
|
readonly id: PermissionId;
|
|
readonly baseline: "authenticated" | "public" | "restricted";
|
|
}>,
|
|
): PermissionRegistry {
|
|
return createPermissionRegistry(
|
|
declarations.map((declaration) => ({ ...declaration, description: "Test permission." })),
|
|
);
|
|
}
|
|
|
|
describe("module HTTP route mounting", () => {
|
|
it('mounts a "/" route at exactly the module root, never with an added trailing slash', async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("root", { status: 200 }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const rootResponse = await app.request("/api/v1/modules/calendar");
|
|
expect(rootResponse.status).toBe(200);
|
|
expect(await rootResponse.text()).toBe("root");
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
const trailingSlashResponse = await app.request("/api/v1/modules/calendar/");
|
|
expect(trailingSlashResponse.status).toBe(404);
|
|
});
|
|
|
|
it("mounts static and nested paths directly under the module prefix", async () => {
|
|
const staticHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
|
|
const nestedHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "nested" }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: staticHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: nestedHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/upcoming/soon",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(await (await app.request("/api/v1/modules/calendar/events")).json()).toEqual({
|
|
kind: "static",
|
|
});
|
|
expect(
|
|
await (await app.request("/api/v1/modules/calendar/events/upcoming/soon")).json(),
|
|
).toEqual({ kind: "nested" });
|
|
});
|
|
|
|
it("resolves a same-depth static path and its parameterized sibling by published route order, without silently reordering them", async () => {
|
|
// Hono's router matches overlapping patterns in registration order, not by specificity —
|
|
// mounting must therefore preserve `StartedModuleRuntime.routes`' exact publication order
|
|
// rather than sorting it, or a module author's more-specific-first declaration would silently
|
|
// stop taking precedence. The static route is declared first here, matching how a module
|
|
// author would order these two routes to get the static path resolved on its own.
|
|
const staticHandler = vi.fn<ModuleRouteHandler>(async () => Response.json({ kind: "static" }));
|
|
const paramHandler = vi.fn<ModuleRouteHandler>(async (_request, context) =>
|
|
Response.json({ id: context.params.eventId, kind: "param" }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: staticHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/upcoming",
|
|
permission: readPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: paramHandler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events/:eventId",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(await (await app.request("/api/v1/modules/calendar/events/upcoming")).json()).toEqual({
|
|
kind: "static",
|
|
});
|
|
expect(staticHandler).toHaveBeenCalledTimes(1);
|
|
expect(paramHandler).not.toHaveBeenCalled();
|
|
|
|
expect(await (await app.request("/api/v1/modules/calendar/events/other-id")).json()).toEqual({
|
|
id: "other-id",
|
|
kind: "param",
|
|
});
|
|
expect(paramHandler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("mounts every supported method", async () => {
|
|
const methods = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
const permissions = new Map(
|
|
methods.map((method) => [method, parsePermissionId(`multi.${method.toLowerCase()}.action`)]),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry(
|
|
methods.map((method) => ({
|
|
baseline: "public" as const,
|
|
id: permissions.get(method) as PermissionId,
|
|
})),
|
|
),
|
|
routes: methods.map((method) =>
|
|
moduleRoute({
|
|
handler: async () => Response.json({ method }),
|
|
method,
|
|
moduleId: "multi",
|
|
path: "/action",
|
|
permission: permissions.get(method) as PermissionId,
|
|
}),
|
|
),
|
|
});
|
|
|
|
for (const method of methods) {
|
|
const response = await app.request("/api/v1/modules/multi/action", {
|
|
headers: method === "GET" ? {} : { origin: trustedOrigin },
|
|
method,
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(await response.json()).toEqual({ method });
|
|
}
|
|
});
|
|
|
|
it("keeps two modules' identical local method/path routes independent", async () => {
|
|
const alphaPermission = parsePermissionId("alpha.items.list");
|
|
const betaPermission = parsePermissionId("beta.items.list");
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([
|
|
{ id: alphaPermission, baseline: "public" },
|
|
{ id: betaPermission, baseline: "public" },
|
|
]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async () => Response.json({ moduleId: "alpha" }),
|
|
method: "GET",
|
|
moduleId: "alpha",
|
|
path: "/items",
|
|
permission: alphaPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ moduleId: "beta" }),
|
|
method: "GET",
|
|
moduleId: "beta",
|
|
path: "/items",
|
|
permission: betaPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
expect(await (await app.request("/api/v1/modules/alpha/items")).json()).toEqual({
|
|
moduleId: "alpha",
|
|
});
|
|
expect(await (await app.request("/api/v1/modules/beta/items")).json()).toEqual({
|
|
moduleId: "beta",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("module route handler adaptation", () => {
|
|
it("adapts the request into a raw Fetch Request and a frozen context with decoded parameters", async () => {
|
|
let capturedRequest: Request | undefined;
|
|
let capturedContext: ModuleRouteHandlerContext | undefined;
|
|
const handler: ModuleRouteHandler = async (request, context) => {
|
|
capturedRequest = request;
|
|
capturedContext = context;
|
|
const body = await request.text();
|
|
return Response.json({ body, url: request.url });
|
|
};
|
|
const runtime = fakeRuntime({
|
|
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_1")),
|
|
});
|
|
const services = fakeModuleServices();
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events/:eventId/rsvp",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
runtime,
|
|
services,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true", {
|
|
body: "hello",
|
|
headers: { origin: trustedOrigin, "x-custom": "abc" },
|
|
method: "POST",
|
|
});
|
|
const body = (await response.json()) as { body: string; url: string };
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(body.body).toBe("hello");
|
|
expect(body.url).toContain("/api/v1/modules/calendar/events/evt-1/rsvp?notify=true");
|
|
expect(capturedRequest).toBeInstanceOf(Request);
|
|
expect(capturedRequest?.headers.get("x-custom")).toBe("abc");
|
|
// Exactly moduleId, params, principal, services — never a Hono Context, authorization store,
|
|
// or configuration reaches a module handler.
|
|
expect(Object.keys(capturedContext ?? {}).sort()).toEqual([
|
|
"moduleId",
|
|
"params",
|
|
"principal",
|
|
"services",
|
|
]);
|
|
expect(capturedContext).toMatchObject({
|
|
moduleId: "calendar",
|
|
params: { eventId: "evt-1" },
|
|
principal: { kind: "authenticated", userId: "user_1" },
|
|
});
|
|
// Reference-identical to the exact services object the caller passed in — never a fresh one
|
|
// built per request.
|
|
expect(capturedContext?.services).toBe(services);
|
|
expect(Object.isFrozen(capturedContext)).toBe(true);
|
|
expect(Object.isFrozen(capturedContext?.params)).toBe(true);
|
|
expect(Object.isFrozen(capturedContext?.services)).toBe(true);
|
|
});
|
|
|
|
it("gives static and root routes a frozen, empty params object", async () => {
|
|
let rootContext: ModuleRouteHandlerContext | undefined;
|
|
let staticContext: ModuleRouteHandlerContext | undefined;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async (_request, context) => {
|
|
rootContext = context;
|
|
return Response.json({ ok: true });
|
|
},
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/",
|
|
permission: readPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async (_request, context) => {
|
|
staticContext = context;
|
|
return Response.json({ ok: true });
|
|
},
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar");
|
|
await app.request("/api/v1/modules/calendar/events");
|
|
|
|
expect(rootContext?.params).toEqual({});
|
|
expect(Object.isFrozen(rootContext?.params)).toBe(true);
|
|
expect(staticContext?.params).toEqual({});
|
|
expect(Object.isFrozen(staticContext?.params)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("module route authorization", () => {
|
|
it("uses the registry's exact baseline for public, authenticated, and restricted module permissions", async () => {
|
|
const publicPermission = parsePermissionId("calendar.events.list");
|
|
const authenticatedPermission = parsePermissionId("calendar.events.subscribe");
|
|
const restrictedPermission = parsePermissionId("calendar.events.read");
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([
|
|
{ id: publicPermission, baseline: "public" },
|
|
{ id: authenticatedPermission, baseline: "authenticated" },
|
|
{ id: restrictedPermission, baseline: "restricted" },
|
|
]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/public",
|
|
permission: publicPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/authenticated",
|
|
permission: authenticatedPermission,
|
|
}),
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/restricted",
|
|
permission: restrictedPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar/public");
|
|
await app.request("/api/v1/modules/calendar/authenticated");
|
|
await app.request("/api/v1/modules/calendar/restricted");
|
|
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ baseline: "public", permission: publicPermission }),
|
|
);
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ baseline: "authenticated", permission: authenticatedPermission }),
|
|
);
|
|
expect(evaluator.authorize).toHaveBeenNthCalledWith(
|
|
3,
|
|
expect.objectContaining({ baseline: "restricted", permission: restrictedPermission }),
|
|
);
|
|
});
|
|
|
|
it("does not invoke the handler or touch the module database when authorization denies", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const { app, services } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expectDatabaseFakeUntouched(services);
|
|
});
|
|
|
|
it("does not invoke the handler when principal resolution fails", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const runtime = fakeRuntime({
|
|
resolvePrincipal: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
runtime,
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(503);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not invoke the handler when the evaluator itself fails", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockRejectedValue(new Error("connection terminated")),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(503);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails app construction safely when a route names a permission the registry does not register", () => {
|
|
expect(() =>
|
|
buildModuleApp({
|
|
registry: permissionRegistry([]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
}),
|
|
).toThrow(ModuleRouteConfigurationError);
|
|
});
|
|
|
|
it("never leaks the offending permission, route path, or module id in the configuration error", () => {
|
|
let captured: unknown;
|
|
try {
|
|
buildModuleApp({
|
|
registry: permissionRegistry([]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "GET",
|
|
moduleId: "secret-module",
|
|
path: "/secret-path",
|
|
permission: parsePermissionId("secret-module.secret.permission"),
|
|
}),
|
|
],
|
|
});
|
|
} catch (error) {
|
|
captured = error;
|
|
}
|
|
|
|
expect(captured).toBeInstanceOf(ModuleRouteConfigurationError);
|
|
const text = `${(captured as Error).message}\n${(captured as Error).stack}`;
|
|
expect(text).not.toContain("secret-module");
|
|
expect(text).not.toContain("secret-path");
|
|
expect(text).not.toContain("secret.permission");
|
|
});
|
|
});
|
|
|
|
describe("module route trusted-origin enforcement", () => {
|
|
it("rejects a state-changing module route with no or untrusted Origin before authorization runs", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const methods = ["POST", "PUT", "PATCH", "DELETE"] as const;
|
|
const { app, services } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: methods.map((method) =>
|
|
moduleRoute({
|
|
handler,
|
|
method,
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
),
|
|
});
|
|
|
|
for (const method of methods) {
|
|
const noOriginResponse = await app.request("/api/v1/modules/calendar/events", { method });
|
|
expect(noOriginResponse.status).toBe(403);
|
|
expect(await noOriginResponse.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
|
|
const untrustedResponse = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: untrustedOrigin },
|
|
method,
|
|
});
|
|
expect(untrustedResponse.status).toBe(403);
|
|
}
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
expectDatabaseFakeUntouched(services);
|
|
});
|
|
|
|
it("allows a trusted-origin state-changing request through to the handler", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not require an Origin header for a GET module route", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("module route CORS", () => {
|
|
it("advertises GET, POST, PUT, PATCH, and DELETE on preflight", async () => {
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "PUT",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { "access-control-request-method": "PUT", origin: trustedOrigin },
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
expect(response.status).toBe(204);
|
|
const allowedMethods = response.headers.get("access-control-allow-methods") ?? "";
|
|
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
|
expect(allowedMethods).toContain(method);
|
|
}
|
|
});
|
|
|
|
it("keeps the OPTIONS preflight's own Vary canonical and never force-injects Cookie, exercising the returned-Response path of the CORS-Vary fix-up", async () => {
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: vi.fn(),
|
|
method: "PUT",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { "access-control-request-method": "PUT", origin: trustedOrigin },
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
const vary = response.headers.get("vary") ?? "";
|
|
const tokens = vary.split(",").map((token) => token.trim());
|
|
// Hono's own cors() unconditionally appends "Access-Control-Request-Headers" here since
|
|
// allowHeaders is non-empty; that is legitimate and must survive canonicalization. Cookie must
|
|
// never be force-injected onto a preflight response, which has no cookie/personalization
|
|
// concept at all — unlike a module response, which always forces it (see mergedVaryHeader).
|
|
expect(tokens).toEqual(["Origin", "Access-Control-Request-Headers"]);
|
|
expect(tokens.filter((token) => token.toLowerCase() === "origin")).toHaveLength(1);
|
|
});
|
|
|
|
it("never invokes authorization, the handler, or the module database for an OPTIONS preflight", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: true }),
|
|
};
|
|
const { app, services } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { "access-control-request-method": "POST", origin: trustedOrigin },
|
|
method: "OPTIONS",
|
|
});
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
expectDatabaseFakeUntouched(services);
|
|
});
|
|
|
|
it("never reflects an untrusted origin for a module route", async () => {
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler: async () => Response.json({ ok: true }),
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: untrustedOrigin },
|
|
});
|
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("module route handler failure sanitization", () => {
|
|
it("returns a fixed sanitized 500 when the handler throws, never leaking the caught message", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => {
|
|
throw new Error("password=hunter2 host=db.internal.example");
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
const text = await response.text();
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(JSON.parse(text)).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
|
expect(text).not.toContain("hunter2");
|
|
expect(text).not.toContain("db.internal.example");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
});
|
|
|
|
it("returns the same fixed sanitized 500 when the handler rejects", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(() => Promise.reject(new Error("rejected")));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
|
});
|
|
|
|
it("returns the same fixed sanitized 500 when the handler returns a non-Response value", async () => {
|
|
const handler = vi.fn(async () => ({ ok: true }) as unknown as Response);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(500);
|
|
expect(await response.json()).toEqual({ code: "MODULE_HANDLER_FAILED" });
|
|
});
|
|
|
|
it("passes through a valid module-owned 4xx/5xx Response unchanged", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () =>
|
|
Response.json({ code: "MODULE_NOT_FOUND" }, { status: 404 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "MODULE_NOT_FOUND" });
|
|
});
|
|
});
|
|
|
|
describe("module route response header policy", () => {
|
|
it("strips module-supplied CORS-control headers so the platform policy remains authoritative", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () =>
|
|
new Response("body", {
|
|
headers: {
|
|
"access-control-allow-credentials": "true",
|
|
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE",
|
|
"access-control-allow-origin": "*",
|
|
"access-control-expose-headers": "X-Secret",
|
|
},
|
|
status: 200,
|
|
}),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: untrustedOrigin },
|
|
});
|
|
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
|
|
expect(response.headers.get("access-control-expose-headers")).toBeNull();
|
|
});
|
|
|
|
it("forces Cache-Control: no-store and Vary: Origin, Cookie on a successful module response", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { "content-type": "text/plain" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
const vary = response.headers.get("vary") ?? "";
|
|
expect(vary).toContain("Origin");
|
|
expect(vary).toContain("Cookie");
|
|
});
|
|
|
|
it("preserves an additional legitimate Vary value from the handler alongside the forced ones", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { Vary: "Accept-Language" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
const vary = response.headers.get("vary") ?? "";
|
|
expect(vary).toContain("Accept-Language");
|
|
expect(vary).toContain("Origin");
|
|
expect(vary).toContain("Cookie");
|
|
});
|
|
|
|
// The following exercise `mergedVaryHeader` directly rather than through the full app: the
|
|
// outer, pre-existing `cors()` middleware (`apps/api/src/app.ts`, unchanged by module routes)
|
|
// unconditionally appends its own "Vary: Origin" to every response with a dynamic-function
|
|
// origin, on top of whatever this module already set — a real Hono behavior, present on every
|
|
// route including `/api/v1/principal` since Phase 4B, not something module routing introduces
|
|
// or can suppress. Testing the merge function directly proves this module's own logic is
|
|
// correct in isolation, without coupling the assertion to that separate middleware's behavior.
|
|
describe("mergedVaryHeader", () => {
|
|
it("collapses an existing * to a bare * instead of the invalid *, Origin, Cookie", () => {
|
|
// RFC 9110 §12.5.5: a Vary field-value of "*" must never be combined with any other value.
|
|
expect(mergedVaryHeader("*")).toBe("*");
|
|
expect(mergedVaryHeader("*, Accept-Language")).toBe("*");
|
|
expect(mergedVaryHeader(" * ")).toBe("*");
|
|
});
|
|
|
|
it("merges case-insensitively so a differently-cased Origin/Cookie never appears as a redundant duplicate", () => {
|
|
expect(mergedVaryHeader("origin, Accept-Language, COOKIE, origin")).toBe(
|
|
"Origin, Accept-Language, Cookie",
|
|
);
|
|
});
|
|
|
|
it("forces canonical Origin/Cookie casing even when absent, and tolerates irregular whitespace", () => {
|
|
expect(mergedVaryHeader(null)).toBe("Origin, Cookie");
|
|
expect(mergedVaryHeader("")).toBe("Origin, Cookie");
|
|
expect(mergedVaryHeader(" Accept-Language , ORIGIN ")).toBe(
|
|
"Accept-Language, Origin, Cookie",
|
|
);
|
|
});
|
|
|
|
it("de-duplicates a literal repeated token with identical casing", () => {
|
|
expect(mergedVaryHeader("Accept-Language, Accept-Language")).toBe(
|
|
"Accept-Language, Origin, Cookie",
|
|
);
|
|
});
|
|
});
|
|
|
|
// The generic app-level CORS-Vary fix-up (`apps/api/src/app.ts`) uses this instead of
|
|
// `mergedVaryHeader`: it must never inject a token (Origin, Cookie, or anything else) that was
|
|
// not already present, since it wraps every response through the shared CORS middleware —
|
|
// including an OPTIONS preflight, which has no Cookie/personalization concept at all.
|
|
describe("canonicalizeVaryHeader", () => {
|
|
it("collapses an existing * to a bare * instead of an invalid combination", () => {
|
|
expect(canonicalizeVaryHeader("*")).toBe("*");
|
|
expect(canonicalizeVaryHeader("Origin, *")).toBe("*");
|
|
});
|
|
|
|
it("de-duplicates case-insensitively without injecting Origin or Cookie", () => {
|
|
expect(canonicalizeVaryHeader("origin, Accept-Encoding, ORIGIN")).toBe(
|
|
"origin, Accept-Encoding",
|
|
);
|
|
});
|
|
|
|
it("never injects a token that was not already present", () => {
|
|
expect(canonicalizeVaryHeader("Accept-Encoding")).toBe("Accept-Encoding");
|
|
expect(canonicalizeVaryHeader("")).toBe("");
|
|
});
|
|
});
|
|
|
|
it("still forces Origin and Cookie into Vary end-to-end even when a handler supplies its own casing", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { Vary: "origin" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
const vary = response.headers.get("vary") ?? "";
|
|
expect(vary).toContain("Origin");
|
|
expect(vary).toContain("Cookie");
|
|
});
|
|
|
|
it("does not read or buffer a streaming response body while sanitizing headers", async () => {
|
|
const chunks = ["first-chunk", "second-chunk"];
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => {
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const chunk of chunks) {
|
|
controller.enqueue(new TextEncoder().encode(chunk));
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(stream, { status: 200 });
|
|
});
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
expect(await response.text()).toBe(chunks.join(""));
|
|
});
|
|
|
|
// These two exercise the *final* response `app.fetch` actually returns — after Hono's own,
|
|
// separate `cors()` middleware (`apps/api/src/app.ts`) has also run and unconditionally appended
|
|
// its own "Vary: Origin" following every module response, on top of whatever this module's own
|
|
// sanitization already set. `sanitizeModuleResponse`'s merge alone cannot prove the wire-level
|
|
// result is well-formed, because cors's append happens strictly afterward, outside this file's
|
|
// code.
|
|
it("keeps the final response's Vary exactly \"*\" end-to-end, even after Hono's own CORS append", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body", { headers: { Vary: "*" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
expect(response.headers.get("vary")).toBe("*");
|
|
});
|
|
|
|
it("collapses a differently-cased existing token with Hono's own appended Origin into one case-insensitive final token", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () =>
|
|
new Response("body", { headers: { Vary: "origin, Accept-Encoding" }, status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
});
|
|
const vary = response.headers.get("vary") ?? "";
|
|
const tokens = vary
|
|
.toLowerCase()
|
|
.split(",")
|
|
.map((token) => token.trim());
|
|
const originOccurrences = tokens.filter((token) => token === "origin").length;
|
|
expect(originOccurrences).toBe(1);
|
|
expect(tokens).toContain("accept-encoding");
|
|
expect(tokens).toContain("cookie");
|
|
});
|
|
});
|
|
|
|
describe("module route HEAD semantics", () => {
|
|
it("traverses the GET route's permission middleware and returns no body when authorized", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body-text", { status: 200 }),
|
|
);
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" });
|
|
expect(response.status).toBe(200);
|
|
expect(await response.text()).toBe("");
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("denies a HEAD request exactly like the GET route would, without invoking the handler", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(
|
|
async () => new Response("body-text", { status: 200 }),
|
|
);
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", { method: "HEAD" });
|
|
expect(response.status).toBe(403);
|
|
expect(await response.text()).toBe("");
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("module route body limits", () => {
|
|
it("rejects a non-empty declared body on a route declaring maxBodyBytes: 0", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: "a",
|
|
headers: { "content-length": "1", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a non-empty streamed body on a route declaring maxBodyBytes: 0", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("a"));
|
|
controller.close();
|
|
},
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: stream,
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts a genuinely empty body on a route declaring maxBodyBytes: 0", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("accepts a body exactly at an explicit maxBodyBytes limit and rejects one byte over", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const limit = 32;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: limit,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const atLimit = "a".repeat(limit);
|
|
const atLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
|
body: atLimit,
|
|
headers: { "content-length": String(limit), origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(atLimitResponse.status).toBe(200);
|
|
|
|
const overLimit = "a".repeat(limit + 1);
|
|
const overLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
|
body: overLimit,
|
|
headers: { "content-length": String(limit + 1), origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
expect(overLimitResponse.status).toBe(413);
|
|
expect(await overLimitResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects an oversized streamed body on a route with an explicit limit, with no declared Content-Length", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const limit = 64;
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: limit,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
const chunk = new Uint8Array(16).fill(97);
|
|
let enqueued = 0;
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
pull(controller) {
|
|
if (enqueued > limit) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
enqueued += chunk.length;
|
|
controller.enqueue(chunk);
|
|
},
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: stream,
|
|
duplex: "half",
|
|
headers: { origin: trustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("enforces the body limit before authorization runs, so an oversized denied request never reaches the evaluator", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const evaluator: AuthorizationEvaluator = {
|
|
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
|
};
|
|
const { app } = buildModuleApp({
|
|
evaluator,
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 8,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events", {
|
|
body: "a".repeat(9),
|
|
headers: { "content-length": "9", origin: trustedOrigin },
|
|
method: "POST",
|
|
});
|
|
|
|
expect(response.status).toBe(413);
|
|
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
|
expect(evaluator.authorize).not.toHaveBeenCalled();
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never reads an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 8,
|
|
method: "POST",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: writePermission,
|
|
}),
|
|
],
|
|
});
|
|
const chunk = new Uint8Array(16).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/modules/calendar/events", {
|
|
body: stream,
|
|
duplex: "half",
|
|
headers: { origin: untrustedOrigin },
|
|
method: "POST",
|
|
} as RequestInit);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies the route's own declared limit to a GET route too", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
maxBodyBytes: 0,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/events");
|
|
expect(response.status).toBe(200);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("module routes and the rest of the API", () => {
|
|
it("does not invoke a handler for an unmatched module path", async () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
const { app } = buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
|
|
const response = await app.request("/api/v1/modules/calendar/does-not-exist");
|
|
expect(response.status).toBe(404);
|
|
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("never invokes a handler merely by constructing the app", () => {
|
|
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
|
buildModuleApp({
|
|
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
|
routes: [
|
|
moduleRoute({
|
|
handler,
|
|
method: "GET",
|
|
moduleId: "calendar",
|
|
path: "/events",
|
|
permission: readPermission,
|
|
}),
|
|
],
|
|
});
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves existing API behavior with zero module routes", async () => {
|
|
const { app } = buildModuleApp({ registry: permissionRegistry([]), routes: [] });
|
|
const response = await app.request("/api/v1/principal");
|
|
expect(response.status).toBe(200);
|
|
});
|
|
});
|