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]>
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([]),
|
|
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);
|
|
});
|
|
});
|