import { describe, expect, it } from "vitest"; import { isModuleHttpMethod, MODULE_HTTP_METHODS, MODULE_ROUTE_MAX_BODY_BYTES_CEILING, 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 { return { method: "GET", path: "/events/:eventId", permission: "calendar.events.read", maxBodyBytes: 0, 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", maxBodyBytes: 0, 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("accepts a maxBodyBytes of exactly zero (no body accepted) and exactly the platform ceiling", () => { expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes: 0 })).success).toBe(true); const atCeiling = safeParseModuleRouteDefinition( validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING }), ); expect(atCeiling.success).toBe(true); if (atCeiling.success) { expect(atCeiling.data.maxBodyBytes).toBe(MODULE_ROUTE_MAX_BODY_BYTES_CEILING); } }); it("rejects a negative, non-integer, non-finite, or over-ceiling maxBodyBytes", () => { for (const maxBodyBytes of [ -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1, ]) { expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes }))).toEqual({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID", }); } }); it("rejects a non-numeric or missing maxBodyBytes", () => { expect( safeParseModuleRouteDefinition({ ...validDefinition(), maxBodyBytes: "0" }), ).toMatchObject({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" }); const { maxBodyBytes: _maxBodyBytes, ...withoutMaxBodyBytes } = validDefinition(); expect(safeParseModuleRouteDefinition(withoutMaxBodyBytes)).toEqual({ success: false, code: "MODULE_ROUTE_DEFINITION_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); } }); });