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
2 changes: 1 addition & 1 deletion packages/control-plane/src/router.auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { routes } from "./routes/catalog";
import {
handleRequest,
matchRoute,
signedServiceRequest,
TEST_BACKGROUND_TASK_CONTEXT,
TEST_SERVICE_SECRETS,
routeContracts as routes,
} from "./router.test-support";

function routeFor(method: string, path: string) {
Expand Down
11 changes: 8 additions & 3 deletions packages/control-plane/src/router.policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { enforceRoutePrincipal } from "./routing/route-admission";
import { routes } from "./routes/catalog";
import { handleRequest, matchRoute, TEST_BACKGROUND_TASK_CONTEXT } from "./router.test-support";
import {
handleRequest,
legacyRoutes,
matchRoute,
routeContracts as routes,
TEST_BACKGROUND_TASK_CONTEXT,
} from "./router.test-support";
import { serviceAllowsPermission } from "./authorization/service-permissions";
import { SCOPED_PERMISSION_PAIRS } from "@open-inspect/shared/rbac";

Expand Down Expand Up @@ -241,7 +246,7 @@ describe("route policy table", () => {

it("returns 400 for a malformed percent-encoded role ID before querying D1", async () => {
const path = "/roles/%E0%A4%A";
const { route, match } = matchRoute(routes, "GET", path)!;
const { route, match } = matchRoute(legacyRoutes(), "GET", path)!;
const prepare = vi.fn();

const response = await route.handler(
Expand Down
2 changes: 1 addition & 1 deletion packages/control-plane/src/router.scm-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { routes } from "./routes/catalog";
import {
handleRequest,
matchRoute,
signedServiceRequest,
TEST_BACKGROUND_TASK_CONTEXT,
TEST_SERVICE_SECRETS,
routeContracts as routes,
} from "./router.test-support";

function routeFor(method: string, path: string) {
Expand Down
70 changes: 61 additions & 9 deletions packages/control-plane/src/router.test-support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +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 { cloudflareHost, createControlPlaneApp } from "./routing/hono-app";
import { routes } from "./routes/catalog";
import { Hono } from "hono";
import { BUILT_IN_ROLE_REGISTRY } from "@open-inspect/shared/rbac";
import type { SqlDatabase, SqlStatement } from "./db/sql-database";
import { cloudflareHost, createControlPlaneApp, type RouteCatalogEntry } 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 { Env } from "./types";

Expand Down Expand Up @@ -41,27 +45,75 @@ function executionContextFromBackgroundTasks(tasks: BackgroundTasks): ExecutionC
* synthetic routes construct their own handler instead of mutating the
* production catalog.
*/
export function createTestRequestHandler(catalog: readonly Route[]): TestRequestHandler {
const app = createControlPlaneApp(catalog, cloudflareHost);
export function createTestRequestHandler(
entries: readonly RouteCatalogEntry[]
): TestRequestHandler {
const app = createControlPlaneApp(entries, cloudflareHost);
return (request, env, backgroundTasks) =>
Promise.resolve(app.fetch(request, env, executionContextFromBackgroundTasks(backgroundTasks)));
}

/** Test-only adapter over the production catalog. */
export const handleRequest: TestRequestHandler = createTestRequestHandler(routes);
export const handleRequest: TestRequestHandler = createTestRequestHandler(catalog);

/** Every production route with its policy, in precedence order, as Hono registered it. */
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(
(contract) => contract.method === method && routePathPattern(contract.path).test(path)
);
}

/**
* A database whose effective-authorization lookup answers with an active
* workspace owner, for request-level unit tests of admitted handlers whose
* data access is mocked at the store.
*/
export function ownerAuthorizationDatabase(userId = "user-1"): SqlDatabase {
return {
prepare(sql: string) {
const statement: SqlStatement = {
bind: () => statement,
first: async <T>() =>
(sql.includes("FROM users u")
? {
user_id: userId,
suspended_at: null,
role_id: BUILT_IN_ROLE_REGISTRY.owner.id,
role_key: "owner",
role_name: "Owner",
}
: null) as T | null,
all: async <T>() => ({ results: [] as T[], meta: { changes: 0 } }),
run: async <T>() => ({ results: [] as T[], meta: { changes: 0 } }),
};
return statement;
},
batch: async () => [],
};
}

/** Compile a catalog path into the legacy raw-path matcher, 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. */
export function matchRoute(
catalog: readonly Route[],
export function matchRoute<Entry extends { method: string; path: string }>(
entries: readonly Entry[],
method: string,
path: string
): { route: Route; match: RegExpMatchArray; params: RouteParams } | undefined {
for (const route of catalog) {
): { route: Entry; match: RegExpMatchArray; 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 } };
Expand Down
135 changes: 72 additions & 63 deletions packages/control-plane/src/routes/analytics.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { analyticsRoutes } from "./analytics";
import type * as AuthenticateModule from "../auth/authenticate";
import { HUMAN_SPAWN_SOURCES } from "../db/analytics-store";
import type { RequestContext } from "./shared";
import type { SqlDatabase } from "../db/sql-database";
import {
createTestRequestHandler,
ownerAuthorizationDatabase,
TEST_BACKGROUND_TASK_CONTEXT,
TEST_SERVICE_SECRETS,
} from "../router.test-support";
import type { Env } from "../types";
import { routePathPattern, TEST_BACKGROUND_TASK_CONTEXT } from "../router.test-support";
import { analyticsRoutes } from "./analytics";

const FIXED_NOW = 1_700_000_000_000;

Expand All @@ -18,6 +22,13 @@ const mockDashboardStore = {
get: vi.fn(),
};

const mocks = vi.hoisted(() => ({ authenticate: vi.fn() }));

vi.mock("../auth/authenticate", async (importOriginal) => ({
...(await importOriginal<typeof AuthenticateModule>()),
authenticate: mocks.authenticate,
}));

vi.mock("../db/analytics-store", async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>;
return {
Expand All @@ -34,46 +45,14 @@ vi.mock("../db/analytics-dashboard-store", () => ({
}),
}));

function getHandler(method: string, path: string) {
const pathname = new URL(`https://test.local${path}`).pathname;
for (const route of analyticsRoutes) {
if (route.method === method && routePathPattern(route.path).test(pathname)) {
const match = pathname.match(routePathPattern(route.path))!;
return { handler: route.handler, match };
}
}

throw new Error(`No route found for ${method} ${path}`);
}

function createEnv(): Env {
return {
DB: {} as D1Database,
} as Env;
}

function createCtx(): RequestContext {
return {
trace_id: "trace-1",
request_id: "req-1",
db: {} as SqlDatabase,
executionCtx: TEST_BACKGROUND_TASK_CONTEXT,
metrics: {
d1Queries: [],
spans: {},
time: async <T>(_name: string, fn: () => Promise<T>) => fn(),
summarize: () => ({}),
},
};
}
const handleRequest = createTestRequestHandler([analyticsRoutes]);
const env = { ...TEST_SERVICE_SECRETS, DB: ownerAuthorizationDatabase() } as unknown as Env;

async function callRoute(method: string, path: string): Promise<Response> {
const { handler, match } = getHandler(method, path);
return handler(
return handleRequest(
new Request(`https://test.local${path}`, { method }),
createEnv(),
match,
createCtx()
env,
TEST_BACKGROUND_TASK_CONTEXT
);
}

Expand All @@ -82,9 +61,17 @@ describe("analytics route handlers", () => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(FIXED_NOW);
mocks.authenticate.mockImplementation(async (request: Request) => ({
principal: { kind: "user", userId: "user-1" },
request,
}));
});

describe("GET /analytics/dashboard", () => {
afterEach(() => {
vi.useRealTimers();
});

describe("dashboard", () => {
it("anchors one shared dashboard window", async () => {
mockDashboardStore.get.mockResolvedValue({ generatedAt: FIXED_NOW });

Expand All @@ -107,26 +94,21 @@ describe("analytics route handlers", () => {
});
});

afterEach(() => {
vi.useRealTimers();
});

describe("GET /analytics/summary", () => {
describe("summary", () => {
it("defaults days to 30", async () => {
mockStore.getSummary.mockResolvedValue({
totalSessions: 12,
activeUsers: 4,
totalCost: 1.5,
avgCost: 0.125,
totalPrs: 2,
statusBreakdown: {
created: 1,
active: 2,
completed: 5,
failed: 2,
archived: 1,
cancelled: 1,
},
totalSessions: 0,
activeUsers: 0,
prsOpened: 0,
prsMerged: 0,
mergeRate: 0,
avgSessionDurationMs: 0,
sessionsByStatus: [],
sessionsByRepo: [],
sessionsByUser: [],
sessionsByModel: [],
prBreakdown: [],
recentSessions: [],
});

const response = await callRoute("GET", "/analytics/summary");
Expand All @@ -148,9 +130,9 @@ describe("analytics route handlers", () => {
});
});

describe("GET /analytics/timeseries", () => {
describe("timeseries", () => {
it("passes the requested range to the store", async () => {
mockStore.getTimeseries.mockResolvedValue({ series: [] });
mockStore.getTimeseries.mockResolvedValue([]);

const response = await callRoute("GET", "/analytics/timeseries?days=14");
expect(response.status).toBe(200);
Expand All @@ -162,7 +144,7 @@ describe("analytics route handlers", () => {
});
});

describe("GET /analytics/breakdown", () => {
describe("breakdown", () => {
it("requires a valid by parameter", async () => {
const response = await callRoute("GET", "/analytics/breakdown?days=30");
expect(response.status).toBe(400);
Expand All @@ -179,5 +161,32 @@ describe("analytics route handlers", () => {
});
expect(mockStore.getBreakdown).not.toHaveBeenCalled();
});

it("passes the breakdown dimension to the store", async () => {
mockStore.getBreakdown.mockResolvedValue([]);

const response = await callRoute("GET", "/analytics/breakdown?days=7&by=repo");
expect(response.status).toBe(200);
expect(mockStore.getBreakdown).toHaveBeenCalledWith(
{
startAt: FIXED_NOW - 7 * 24 * 60 * 60 * 1000,
endAt: FIXED_NOW,
spawnSources: HUMAN_SPAWN_SOURCES,
},
"repo"
);
});
});

it("denies a request without analytics permission before touching a store", async () => {
mocks.authenticate.mockImplementation(async () => ({
reason: "Unauthorized",
status: 401,
failedScheme: "none",
}));

const response = await callRoute("GET", "/analytics/summary");
expect(response.status).toBe(401);
expect(mockStore.getSummary).not.toHaveBeenCalled();
});
});
Loading
Loading