Phase 5B, pass 1: framework-neutral module HTTP route contracts and runtime registration. Adds @ekklesia/contracts' module-http.ts (methods, canonical path grammar, route definition/context/handler types, and validation) and Core's module runtime staging/publication of a module's declared routes alongside its capabilities, gated on the module's own manifest permissions. Routes are collected into StartedModuleRuntime as an immutable snapshot but are not yet mounted into apps/api; that is pass 2. Co-Authored-By: Claude Sonnet 5 <[email protected]>
256 lines
9.2 KiB
TypeScript
256 lines
9.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
isModuleHttpMethod,
|
|
MODULE_HTTP_METHODS,
|
|
type ModuleRouteDefinitionInput,
|
|
moduleRoutePathSchema,
|
|
moduleRouteShapeKey,
|
|
parseModuleRouteDefinition,
|
|
parseModuleRoutePath,
|
|
safeParseModuleRouteDefinition,
|
|
safeParseModuleRoutePath,
|
|
} from "../src/index.js";
|
|
|
|
function noopHandler(): Response {
|
|
return new Response(null, { status: 204 });
|
|
}
|
|
|
|
function validDefinition(
|
|
overrides: Partial<ModuleRouteDefinitionInput> = {},
|
|
): ModuleRouteDefinitionInput {
|
|
return {
|
|
method: "GET",
|
|
path: "/events/:eventId",
|
|
permission: "calendar.events.read",
|
|
handler: noopHandler,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("ModuleHttpMethod", () => {
|
|
it("accepts every supported method", () => {
|
|
for (const method of MODULE_HTTP_METHODS) {
|
|
expect(isModuleHttpMethod(method)).toBe(true);
|
|
}
|
|
expect(MODULE_HTTP_METHODS).toEqual(["DELETE", "GET", "PATCH", "POST", "PUT"]);
|
|
});
|
|
|
|
it("rejects lowercase, unknown, OPTIONS, HEAD, and wildcard methods", () => {
|
|
for (const method of ["get", "post", "TRACE", "OPTIONS", "HEAD", "*", "ALL", ""]) {
|
|
expect(isModuleHttpMethod(method)).toBe(false);
|
|
}
|
|
expect(isModuleHttpMethod(42)).toBe(false);
|
|
expect(isModuleHttpMethod(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("moduleRoutePathSchema", () => {
|
|
it("accepts the root, static, nested, and parameterized canonical paths", () => {
|
|
for (const path of [
|
|
"/",
|
|
"/events",
|
|
"/events/upcoming",
|
|
"/events/:eventId",
|
|
"/events/:eventId/attendees/:attendeeId",
|
|
]) {
|
|
expect(parseModuleRoutePath(path)).toBe(path);
|
|
}
|
|
});
|
|
|
|
it("rejects paths that do not begin with /", () => {
|
|
expect(safeParseModuleRoutePath("events").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("").success).toBe(false);
|
|
});
|
|
|
|
it("rejects a non-root path ending with /", () => {
|
|
expect(safeParseModuleRoutePath("/events/").success).toBe(false);
|
|
});
|
|
|
|
it("rejects empty and repeated segments", () => {
|
|
expect(safeParseModuleRoutePath("/events//upcoming").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("//events").success).toBe(false);
|
|
});
|
|
|
|
it("rejects query strings and fragments", () => {
|
|
expect(safeParseModuleRoutePath("/events?limit=10").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events#upcoming").success).toBe(false);
|
|
});
|
|
|
|
it("rejects backslashes and percent-encoded path syntax", () => {
|
|
expect(safeParseModuleRoutePath("/events\\upcoming").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events%2Fupcoming").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/%2e%2e/events").success).toBe(false);
|
|
});
|
|
|
|
it("rejects dot segments and traversal syntax", () => {
|
|
expect(safeParseModuleRoutePath("/events/..").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events/.").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/../events").success).toBe(false);
|
|
});
|
|
|
|
it("rejects wildcard or catch-all segments", () => {
|
|
expect(safeParseModuleRoutePath("/events/*").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events/**").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/*").success).toBe(false);
|
|
});
|
|
|
|
it("rejects non-kebab-case static segments", () => {
|
|
expect(safeParseModuleRoutePath("/Events").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events_list").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events--upcoming").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/-events").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events-").success).toBe(false);
|
|
});
|
|
|
|
it("rejects a malformed parameter segment", () => {
|
|
expect(safeParseModuleRoutePath("/events/:").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events/:Event-Id").success).toBe(false);
|
|
expect(safeParseModuleRoutePath("/events/:1id").success).toBe(false);
|
|
});
|
|
|
|
it("rejects duplicate parameter names within one path", () => {
|
|
expect(safeParseModuleRoutePath("/events/:id/attendees/:id").success).toBe(false);
|
|
});
|
|
|
|
it("rejects an unreasonably long path", () => {
|
|
expect(safeParseModuleRoutePath(`/${"a".repeat(300)}`).success).toBe(false);
|
|
});
|
|
|
|
it("never echoes the rejected raw path in its issue messages", () => {
|
|
const secret = "/machine/private/path/traversal-attempt-%2e%2e";
|
|
const result = moduleRoutePathSchema.safeParse(secret);
|
|
expect(result.success).toBe(false);
|
|
expect(JSON.stringify(result.error?.issues)).not.toContain(secret);
|
|
});
|
|
});
|
|
|
|
describe("moduleRouteShapeKey", () => {
|
|
it("collapses different parameter names into the same shape for the same method", () => {
|
|
const eventId = parseModuleRoutePath("/events/:eventId");
|
|
const id = parseModuleRoutePath("/events/:id");
|
|
expect(moduleRouteShapeKey("GET", eventId)).toBe(moduleRouteShapeKey("GET", id));
|
|
});
|
|
|
|
it("distinguishes shapes by method and by static path structure", () => {
|
|
const path = parseModuleRoutePath("/events/:eventId");
|
|
expect(moduleRouteShapeKey("GET", path)).not.toBe(moduleRouteShapeKey("POST", path));
|
|
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/events"))).not.toBe(
|
|
moduleRouteShapeKey("GET", parseModuleRoutePath("/events/:eventId")),
|
|
);
|
|
});
|
|
|
|
it("treats the root path as its own shape", () => {
|
|
expect(moduleRouteShapeKey("GET", parseModuleRoutePath("/"))).toBe("GET /");
|
|
});
|
|
});
|
|
|
|
describe("safeParseModuleRouteDefinition", () => {
|
|
it("accepts a well-formed definition and brands its path and permission", () => {
|
|
const result = safeParseModuleRouteDefinition(validDefinition());
|
|
expect(result.success).toBe(true);
|
|
if (result.success) {
|
|
expect(result.data).toEqual({
|
|
method: "GET",
|
|
path: "/events/:eventId",
|
|
permission: "calendar.events.read",
|
|
handler: noopHandler,
|
|
});
|
|
expect(Object.isFrozen(result.data)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("rejects a non-object, null, or array definition", () => {
|
|
for (const value of [null, undefined, "route", 42, []]) {
|
|
expect(safeParseModuleRouteDefinition(value)).toEqual({
|
|
success: false,
|
|
code: "MODULE_ROUTE_DEFINITION_INVALID",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("rejects an unknown route-definition field", () => {
|
|
expect(safeParseModuleRouteDefinition({ ...validDefinition(), baseline: "public" })).toEqual({
|
|
success: false,
|
|
code: "MODULE_ROUTE_DEFINITION_INVALID",
|
|
});
|
|
expect(
|
|
safeParseModuleRouteDefinition({ ...validDefinition(), mountPrefix: "/custom" }),
|
|
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
|
|
});
|
|
|
|
it("rejects a missing required field", () => {
|
|
const { handler: _handler, ...withoutHandler } = validDefinition();
|
|
expect(safeParseModuleRouteDefinition(withoutHandler)).toEqual({
|
|
success: false,
|
|
code: "MODULE_ROUTE_DEFINITION_INVALID",
|
|
});
|
|
});
|
|
|
|
it("rejects a non-function handler", () => {
|
|
expect(
|
|
safeParseModuleRouteDefinition({ ...validDefinition(), handler: "not-a-function" }),
|
|
).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_INVALID" });
|
|
});
|
|
|
|
it("rejects an invalid method", () => {
|
|
expect(safeParseModuleRouteDefinition({ ...validDefinition(), method: "get" })).toEqual({
|
|
success: false,
|
|
code: "MODULE_ROUTE_METHOD_INVALID",
|
|
});
|
|
});
|
|
|
|
it("rejects an invalid path", () => {
|
|
expect(safeParseModuleRouteDefinition({ ...validDefinition(), path: "events" })).toEqual({
|
|
success: false,
|
|
code: "MODULE_ROUTE_PATH_INVALID",
|
|
});
|
|
});
|
|
|
|
it("rejects an invalid permission id", () => {
|
|
expect(
|
|
safeParseModuleRouteDefinition({ ...validDefinition(), permission: "not namespaced" }),
|
|
).toEqual({ success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" });
|
|
expect(safeParseModuleRouteDefinition({ ...validDefinition(), permission: "*" })).toEqual({
|
|
success: false,
|
|
code: "MODULE_ROUTE_PERMISSION_INVALID",
|
|
});
|
|
});
|
|
|
|
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
|
|
const source = { ...validDefinition() };
|
|
const result = safeParseModuleRouteDefinition(source);
|
|
expect(result.success).toBe(true);
|
|
source.method = "POST";
|
|
source.path = "/mutated";
|
|
source.permission = "mutated.permission.id";
|
|
if (result.success) {
|
|
expect(result.data.method).toBe("GET");
|
|
expect(result.data.path).toBe("/events/:eventId");
|
|
expect(result.data.permission).toBe("calendar.events.read");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("parseModuleRouteDefinition", () => {
|
|
it("returns the parsed definition for valid input", () => {
|
|
expect(parseModuleRouteDefinition(validDefinition())).toMatchObject({
|
|
method: "GET",
|
|
path: "/events/:eventId",
|
|
permission: "calendar.events.read",
|
|
});
|
|
});
|
|
|
|
it("throws without echoing the rejected input", () => {
|
|
const secret = "secret-token-value";
|
|
expect(() => parseModuleRouteDefinition({ ...validDefinition(), permission: secret })).toThrow(
|
|
"Invalid Ekklesia module route definition.",
|
|
);
|
|
try {
|
|
parseModuleRouteDefinition({ ...validDefinition(), permission: secret });
|
|
throw new Error("expected parseModuleRouteDefinition to throw");
|
|
} catch (error) {
|
|
expect(String(error)).not.toContain(secret);
|
|
}
|
|
});
|
|
});
|