Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 96 additions & 104 deletions packages/control-plane/src/router.authorization-audit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,18 @@ import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac";
import type * as AuthenticateModule from "./auth/authenticate";
import type { Principal } from "./auth/principal";
import type { SqlDatabase, SqlStatement } from "./db/sql-database";
import { Hono } from "hono";
import {
defineRoute,
json,
GITHUB_SANDBOX_FALLBACK_ROUTE,
permissionRequirement,
requireAll,
requireAutomation,
requirePermission,
serviceAuthorized,
type Route,
} from "./routes/shared";
import { admit } from "./routing/admit";
import type { ControlPlaneHonoEnv } from "./routing/hono-env";
import { createTestRequestHandler, TEST_BACKGROUND_TASK_CONTEXT } from "./router.test-support";

const mocks = vi.hoisted(() => ({ authenticate: vi.fn() }));
Expand All @@ -23,109 +24,100 @@ vi.mock("./auth/authenticate", async (importOriginal) => ({
authenticate: mocks.authenticate,
}));

const TEST_ROUTES: Route[] = [
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "POST",
path: "/audit-test/actorless-service",
authorization: requirePermission("sessions.lifecycle", {
actorlessGrants: [{ service: "github-bot" }],
}),
handler: async () => json({ handled: true }),
}
),
defineRoute(
{ authentication: { kind: "user" }, supportedScmProviders: "all" },
{
method: "POST",
path: "/audit-test/user-only",
authorization: requirePermission("workspace.members.manage"),
handler: async () => json({ handled: true }),
}
),
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "POST",
path: "/audit-test/automations/:id/pause",
authorization: requireAutomation("manage"),
handler: async () => json({ handled: true }),
}
),
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "POST",
path: "/audit-test/managed",
authorization: requirePermission("workspace.members.manage"),
handler: async () => json({ handled: true }, 201),
}
),
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "GET",
path: "/audit-test/managed",
authorization: requirePermission("workspace.members.manage"),
handler: async () => json({ handled: true }),
}
),
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "GET",
path: "/audit-test/profiles",
authorization: requirePermission("skill_profiles.manage_own"),
handler: async () => json({ handled: true }),
}
),
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "GET",
path: "/audit-test/read",
authorization: requirePermission("workspace.roles.read"),
handler: async () => json({ handled: true }),
}
),
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "POST",
path: "/audit-test/service-actor",
authorization: requirePermission("sessions.lifecycle"),
handler: async () => json({ handled: true }, 201),
}
),
defineRoute(
{ authentication: { kind: "service" }, supportedScmProviders: "all" },
{
method: "POST",
path: "/audit-test/service",
authorization: serviceAuthorized("github-bot", "required"),
handler: async () => json({ handled: true }),
}
),
defineRoute(
{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
{
method: "POST",
path: "/audit-test/multi",
authorization: requireAll(
permissionRequirement("analytics.read"),
permissionRequirement("workspace.members.manage")
),
handler: async () => json({ handled: true }),
}
),
defineRoute(GITHUB_SANDBOX_FALLBACK_ROUTE, {
method: "POST",
path: "/audit-test/sessions/:id/upload",
const TEST_ROUTES = new Hono<ControlPlaneHonoEnv>();
TEST_ROUTES.post(
"/audit-test/actorless-service",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requirePermission("sessions.lifecycle", {
actorlessGrants: [{ service: "github-bot" }],
}),
}),
() => json({ handled: true })
);
TEST_ROUTES.post(
"/audit-test/user-only",
admit({
...{ authentication: { kind: "user" }, supportedScmProviders: "all" },
authorization: requirePermission("workspace.members.manage"),
}),
() => json({ handled: true })
);
TEST_ROUTES.post(
"/audit-test/automations/:id/pause",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requireAutomation("manage"),
}),
() => json({ handled: true })
);
TEST_ROUTES.post(
"/audit-test/managed",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requirePermission("workspace.members.manage"),
}),
() => json({ handled: true }, 201)
);
TEST_ROUTES.get(
"/audit-test/managed",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requirePermission("workspace.members.manage"),
}),
() => json({ handled: true })
);
TEST_ROUTES.get(
"/audit-test/profiles",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requirePermission("skill_profiles.manage_own"),
}),
() => json({ handled: true })
);
TEST_ROUTES.get(
"/audit-test/read",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requirePermission("workspace.roles.read"),
}),
() => json({ handled: true })
);
TEST_ROUTES.post(
"/audit-test/service-actor",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requirePermission("sessions.lifecycle"),
}),
() => json({ handled: true }, 201)
);
TEST_ROUTES.post(
"/audit-test/service",
admit({
...{ authentication: { kind: "service" }, supportedScmProviders: "all" },
authorization: serviceAuthorized("github-bot", "required"),
}),
() => json({ handled: true })
);
TEST_ROUTES.post(
"/audit-test/multi",
admit({
...{ authentication: { kind: "user-or-service" }, supportedScmProviders: "all" },
authorization: requireAll(
permissionRequirement("analytics.read"),
permissionRequirement("workspace.members.manage")
),
}),
() => json({ handled: true })
);
TEST_ROUTES.post(
"/audit-test/sessions/:id/upload",
admit({
...GITHUB_SANDBOX_FALLBACK_ROUTE,
authorization: requirePermission("sessions.collaborate"),
handler: async () => json({ handled: true }, 201),
}),
];
() => json({ handled: true }, 201)
);

interface AuditWrite {
values: unknown[];
Expand Down Expand Up @@ -223,7 +215,7 @@ function auditRecord(write: AuditWrite) {
};
}

const handleRequest = createTestRequestHandler(TEST_ROUTES);
const handleRequest = createTestRequestHandler([TEST_ROUTES]);

beforeEach(() => {
mocks.authenticate.mockReset();
Expand Down
28 changes: 10 additions & 18 deletions packages/control-plane/src/router.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@
import { buildServiceAuthHeaders, type ServiceName } from "@open-inspect/shared/service-auth";
import type { BackgroundTasks } from "./platform-ports";
import { createTestBackgroundTasks } from "./background-tasks.test-support";
import { Hono } from "hono";
import { BUILT_IN_ROLE_REGISTRY, type PermissionId } from "@open-inspect/shared/rbac";
import type { SqlDatabase, SqlStatement } from "./db/sql-database";
import { cloudflareHost, createControlPlaneApp, type RouteCatalogEntry } from "./routing/hono-app";
import { cloudflareHost, createControlPlaneApp, type RouteModule } from "./routing/hono-app";
import { listRouteContracts, type RouteContract } from "./routing/route-contracts";
import { catalog } from "./routes/catalog";
import type { Route, RouteParams } from "./routes/shared";
import type { RouteParams } from "./routes/shared";
import type { Env } from "./types";

// The single contract-faithful double lives in background-tasks.test-support;
Expand All @@ -40,15 +39,13 @@ function executionContextFromBackgroundTasks(tasks: BackgroundTasks): ExecutionC
}

/**
* Test-only adapter over an explicit catalog, through the production host.
* Test-only adapter over explicit route modules, through the production host.
* Hono registers routes when the app is built, so fixtures that need
* synthetic routes construct their own handler instead of mutating the
* synthetic routes build their own module instead of mutating the
* production catalog.
*/
export function createTestRequestHandler(
entries: readonly RouteCatalogEntry[]
): TestRequestHandler {
const app = createControlPlaneApp(entries, cloudflareHost);
export function createTestRequestHandler(modules: readonly RouteModule[]): TestRequestHandler {
const app = createControlPlaneApp(modules, cloudflareHost);
return (request, env, backgroundTasks) =>
Promise.resolve(app.fetch(request, env, executionContextFromBackgroundTasks(backgroundTasks)));
}
Expand All @@ -61,11 +58,6 @@ export const routeContracts: readonly RouteContract[] = listRouteContracts(
createControlPlaneApp(catalog, cloudflareHost)
);

/** The catalog entries still registered through the legacy adapter. */
export function legacyRoutes(entries: readonly RouteCatalogEntry[] = catalog): Route[] {
return entries.filter((entry): entry is Route => !(entry instanceof Hono));
}

/** The production contract selected for a concrete method and path. */
export function contractFor(method: string, path: string): RouteContract | undefined {
return routeContracts.find(
Expand Down Expand Up @@ -143,21 +135,21 @@ export function ownerAuthorizationDatabase(userId = TEST_USER_ID): SqlDatabase {
return authorizationDatabase({ userId });
}

/** Compile a catalog path into the legacy raw-path matcher, for handler-level fixtures. */
/** Compile a route path into a matcher over a concrete pathname, for handler-level fixtures. */
export function routePathPattern(path: string): RegExp {
return new RegExp(`^${path.replace(/:(\w+)/g, "(?<$1>[^/]+)")}$`);
}

/** Select the catalog route for a concrete path and rebuild what the adapter hands its handler. */
/** Select the first contract for a concrete method and path, with the raw parameters it binds. */
export function matchRoute<Entry extends { method: string; path: string }>(
entries: readonly Entry[],
method: string,
path: string
): { route: Entry; match: RegExpMatchArray; params: RouteParams } | undefined {
): { route: Entry; params: RouteParams } | undefined {
for (const route of entries) {
if (route.method !== method) continue;
const match = path.match(routePathPattern(route.path));
if (match) return { route, match, params: { ...match.groups } };
if (match) return { route, params: { ...match.groups } };
}
return undefined;
}
Expand Down
9 changes: 3 additions & 6 deletions packages/control-plane/src/routes/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* and parameterized paths.
*/

import type { RouteCatalogEntry } from "../routing/hono-env";
import type { RouteModule } from "../routing/hono-env";
import { webhookRoutes } from "../webhooks";
import { analyticsRoutes } from "./analytics";
import { auditEventRoutes } from "./audit-events";
Expand All @@ -31,11 +31,8 @@ import { slackNotifyRoutes } from "./slack-notify";
import { signInProviderRoutes } from "./sign-in-providers";
import { skillRoutes } from "./skills";

/**
* Registration order is the precedence order. A Hono sub-app is mounted where
* it appears; a legacy route is registered through the catalog adapter.
*/
export const catalog: RouteCatalogEntry[] = [
/** Registration order is the precedence order: each module is mounted where it appears. */
export const catalog: readonly RouteModule[] = [
healthRoutes,

browserAuthRoutes,
Expand Down
21 changes: 21 additions & 0 deletions packages/control-plane/src/routes/repository-params.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { repositoryParams } from "./repository-params";

describe("repositoryParams", () => {
it("accepts a nested owner namespace Hono decoded from one segment", () => {
expect(repositoryParams({ owner: "group/subgroup", name: "web" })).toEqual({
owner: "group/subgroup",
name: "web",
});
});

it("rejects a slash in the repository name with the route's 400", async () => {
const result = repositoryParams({ owner: "group", name: "web/api" });

expect(result).toBeInstanceOf(Response);
expect((result as Response).status).toBe(400);
await expect((result as Response).json()).resolves.toEqual({
error: "Owner and name must be valid repository path segments",
});
});
});
28 changes: 0 additions & 28 deletions packages/control-plane/src/routes/shared.test.ts

This file was deleted.

Loading
Loading