diff --git a/packages/control-plane/src/routes/analytics.test.ts b/packages/control-plane/src/routes/analytics.test.ts index 7353017b0..a6e297987 100644 --- a/packages/control-plane/src/routes/analytics.test.ts +++ b/packages/control-plane/src/routes/analytics.test.ts @@ -8,7 +8,7 @@ import { TEST_SERVICE_SECRETS, } from "../router.test-support"; import type { Env } from "../types"; -import { analyticsRoutes } from "./analytics"; +import { analyticsRoutes, DEFAULT_ANALYTICS_DAYS } from "./analytics"; const FIXED_NOW = 1_700_000_000_000; @@ -114,7 +114,7 @@ describe("analytics route handlers", () => { const response = await callRoute("GET", "/analytics/summary"); expect(response.status).toBe(200); expect(mockStore.getSummary).toHaveBeenCalledWith({ - startAt: FIXED_NOW - 30 * 24 * 60 * 60 * 1000, + startAt: FIXED_NOW - DEFAULT_ANALYTICS_DAYS * 24 * 60 * 60 * 1000, endAt: FIXED_NOW, spawnSources: HUMAN_SPAWN_SOURCES, }); @@ -178,6 +178,57 @@ describe("analytics route handlers", () => { }); }); + describe("query strings", () => { + it.each(["7", "14", "30", "90"])("accepts days=%s", async (days) => { + mockStore.getSummary.mockResolvedValue({ ok: true }); + + const response = await callRoute("GET", `/analytics/summary?days=${days}`); + + expect(response.status).toBe(200); + expect(mockStore.getSummary).toHaveBeenCalledWith( + expect.objectContaining({ + startAt: FIXED_NOW - Number(days) * 24 * 60 * 60 * 1000, + endAt: FIXED_NOW, + }) + ); + }); + + it.each(["", "0", "8", "abc", "1e1"])("rejects days=%s", async (days) => { + const response = await callRoute("GET", `/analytics/summary?days=${days}`); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "days must be one of: 7, 14, 30, 90", + }); + expect(mockStore.getSummary).not.toHaveBeenCalled(); + }); + + it("rejects a repeated days key", async () => { + const response = await callRoute("GET", "/analytics/summary?days=7&days=14"); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid days" }); + }); + + it("rejects an empty or repeated by key", async () => { + const empty = await callRoute("GET", "/analytics/breakdown?days=30&by="); + expect(empty.status).toBe(400); + await expect(empty.json()).resolves.toEqual({ error: "by must be one of: user, repo" }); + + const repeated = await callRoute("GET", "/analytics/breakdown?days=30&by=user&by=repo"); + expect(repeated.status).toBe(400); + await expect(repeated.json()).resolves.toEqual({ error: "Invalid by" }); + }); + + it("reports days before by when both are invalid", async () => { + const response = await callRoute("GET", "/analytics/breakdown?days=1&by=nope"); + + await expect(response.json()).resolves.toEqual({ + error: "days must be one of: 7, 14, 30, 90", + }); + }); + }); + it("denies a request without analytics permission before touching a store", async () => { mocks.authenticate.mockImplementation(async () => ({ reason: "Unauthorized", diff --git a/packages/control-plane/src/routes/analytics.ts b/packages/control-plane/src/routes/analytics.ts index 44435b8f8..1750f5ead 100644 --- a/packages/control-plane/src/routes/analytics.ts +++ b/packages/control-plane/src/routes/analytics.ts @@ -1,7 +1,6 @@ import { ANALYTICS_BREAKDOWN_BY, ANALYTICS_DAYS, - type AnalyticsBreakdownBy, type AnalyticsDays, } from "@open-inspect/shared/types/analytics"; import { type AnalyticsFilters, AnalyticsStore, HUMAN_SPAWN_SOURCES } from "../db/analytics-store"; @@ -11,29 +10,35 @@ import { PullRequestAnalyticsStore, } from "../db/pull-request-analytics-store"; import { Hono } from "hono"; +import { z } from "zod"; import { admit } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; +import { parseQuery } from "./query"; import { type RequestContext, SCM_AGNOSTIC_USER_OR_SERVICE_ROUTE, - error, json, requirePermission, } from "./shared"; -function parseDaysParam(value: string | null): AnalyticsDays | null { - if (value === null) return 30; - - const parsed = Number(value); - return ANALYTICS_DAYS.includes(parsed as AnalyticsDays) ? (parsed as AnalyticsDays) : null; -} +export const DEFAULT_ANALYTICS_DAYS: AnalyticsDays = 30; + +/** The reporting window; absent, the default. The value is read the way `Number()` reads it. */ +const daysQuery = z.object({ + days: z + .string() + .optional() + .transform((raw) => (raw === undefined ? DEFAULT_ANALYTICS_DAYS : Number(raw))) + .pipe( + z.literal(ANALYTICS_DAYS, { error: `days must be one of: ${ANALYTICS_DAYS.join(", ")}` }) + ), +}); -function parseBreakdownBy(value: string | null): AnalyticsBreakdownBy | null { - if (!value) return null; - return ANALYTICS_BREAKDOWN_BY.includes(value as AnalyticsBreakdownBy) - ? (value as AnalyticsBreakdownBy) - : null; -} +const breakdownQuery = daysQuery.extend({ + by: z.enum(ANALYTICS_BREAKDOWN_BY, { + error: `by must be one of: ${ANALYTICS_BREAKDOWN_BY.join(", ")}`, + }), +}); function getFilters(days: AnalyticsDays): AnalyticsFilters { const endAt = Date.now(); @@ -52,11 +57,9 @@ function getPullRequestFilters(days: AnalyticsDays): PullRequestAnalyticsFilters } async function handleDashboard(request: Request, ctx: RequestContext): Promise { - const url = new URL(request.url); - const days = parseDaysParam(url.searchParams.get("days")); - if (!days) { - return error(`days must be one of: ${ANALYTICS_DAYS.join(", ")}`, 400); - } + const query = parseQuery(request, daysQuery); + if (query instanceof Response) return query; + const { days } = query; const generatedAt = Date.now(); const store = new AnalyticsDashboardStore(ctx.db); @@ -70,50 +73,36 @@ async function handleDashboard(request: Request, ctx: RequestContext): Promise { - const url = new URL(request.url); - const days = parseDaysParam(url.searchParams.get("days")); - if (!days) { - return error(`days must be one of: ${ANALYTICS_DAYS.join(", ")}`, 400); - } + const query = parseQuery(request, daysQuery); + if (query instanceof Response) return query; + const { days } = query; const store = new AnalyticsStore(ctx.db); return json(await store.getSummary(getFilters(days))); } async function handleTimeseries(request: Request, ctx: RequestContext): Promise { - const url = new URL(request.url); - const days = parseDaysParam(url.searchParams.get("days")); - if (!days) { - return error(`days must be one of: ${ANALYTICS_DAYS.join(", ")}`, 400); - } + const query = parseQuery(request, daysQuery); + if (query instanceof Response) return query; + const { days } = query; const store = new AnalyticsStore(ctx.db); return json(await store.getTimeseries(getFilters(days))); } async function handleBreakdown(request: Request, ctx: RequestContext): Promise { - const url = new URL(request.url); - const days = parseDaysParam(url.searchParams.get("days")); - if (!days) { - return error(`days must be one of: ${ANALYTICS_DAYS.join(", ")}`, 400); - } - - const byParam = url.searchParams.get("by"); - const by = parseBreakdownBy(byParam); - if (!by) { - return error(`by must be one of: ${ANALYTICS_BREAKDOWN_BY.join(", ")}`, 400); - } + const query = parseQuery(request, breakdownQuery); + if (query instanceof Response) return query; + const { days, by } = query; const store = new AnalyticsStore(ctx.db); return json(await store.getBreakdown(getFilters(days), by)); } async function handlePullRequests(request: Request, ctx: RequestContext): Promise { - const url = new URL(request.url); - const days = parseDaysParam(url.searchParams.get("days")); - if (!days) { - return error(`days must be one of: ${ANALYTICS_DAYS.join(", ")}`, 400); - } + const query = parseQuery(request, daysQuery); + if (query instanceof Response) return query; + const { days } = query; const store = new PullRequestAnalyticsStore(ctx.db); return json(await store.get(getPullRequestFilters(days))); diff --git a/packages/control-plane/src/routes/audit-events.test.ts b/packages/control-plane/src/routes/audit-events.test.ts new file mode 100644 index 000000000..31521f20f --- /dev/null +++ b/packages/control-plane/src/routes/audit-events.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type * as AuthenticateModule from "../auth/authenticate"; +import { encodeAuditEventCursor } from "../db/audit-event-cursor"; +import { + createTestRequestHandler, + ownerAuthorizationDatabase, + TEST_BACKGROUND_TASK_CONTEXT, + TEST_SERVICE_SECRETS, +} from "../router.test-support"; +import type { Env } from "../types"; +import { auditEventRoutes, DEFAULT_AUDIT_EVENT_LIMIT } from "./audit-events"; + +const mockStore = { list: vi.fn() }; +const mocks = vi.hoisted(() => ({ authenticate: vi.fn() })); + +vi.mock("../auth/authenticate", async (importOriginal) => ({ + ...(await importOriginal()), + authenticate: mocks.authenticate, +})); + +vi.mock("../db/audit-event-store", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + AuditEventStore: vi.fn().mockImplementation(function () { + return mockStore; + }), + }; +}); + +const handleRequest = createTestRequestHandler([auditEventRoutes]); +const env = { ...TEST_SERVICE_SECRETS, DB: ownerAuthorizationDatabase() } as unknown as Env; + +function list(query = ""): Promise { + return handleRequest( + new Request(`https://test.local/audit-events${query}`), + env, + TEST_BACKGROUND_TASK_CONTEXT + ); +} + +describe("audit events route", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authenticate.mockImplementation(async (request: Request) => ({ + principal: { kind: "user", userId: "user-1" }, + request, + })); + mockStore.list.mockResolvedValue({ rows: [], hasMore: false, nextCursor: null }); + }); + + it("defaults the limit with no cursor", async () => { + const response = await list(); + + expect(response.status).toBe(200); + expect(mockStore.list).toHaveBeenCalledWith({ limit: DEFAULT_AUDIT_EVENT_LIMIT, cursor: null }); + await expect(response.json()).resolves.toEqual({ + events: [], + hasMore: false, + nextCursor: null, + }); + }); + + it("accepts the maximum limit", async () => { + expect((await list("?limit=100")).status).toBe(200); + expect(mockStore.list).toHaveBeenCalledWith({ limit: 100, cursor: null }); + }); + + it("round-trips a cursor through the store and the next page", async () => { + const cursor = { occurredAt: 1_700_000_000_000, id: "event-1" }; + const next = { occurredAt: 1_699_999_999_000, id: "event-2" }; + mockStore.list.mockResolvedValue({ rows: [], hasMore: true, nextCursor: next }); + + const response = await list(`?limit=1&cursor=${encodeAuditEventCursor(cursor)}`); + + expect(response.status).toBe(200); + expect(mockStore.list).toHaveBeenCalledWith({ limit: 1, cursor }); + await expect(response.json()).resolves.toMatchObject({ + hasMore: true, + nextCursor: encodeAuditEventCursor(next), + }); + }); + + it.each(["0", "101", "1.5", "1e2", "+5", "-5", "abc", "", "01"])( + "rejects limit=%s before reading the store", + async (limit) => { + const response = await list(`?limit=${limit}`); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid limit" }); + expect(mockStore.list).not.toHaveBeenCalled(); + } + ); + + it.each(["", "not-a-cursor", "100:", ":event-1"])( + "rejects cursor=%s before reading the store", + async (cursor) => { + const response = await list(`?cursor=${cursor}`); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: "Invalid cursor" }); + expect(mockStore.list).not.toHaveBeenCalled(); + } + ); + + it.each([ + ["limit=1&limit=2", "Invalid limit"], + ["cursor=one&cursor=two", "Invalid cursor"], + ])("rejects a repeated key (%s)", async (query, message) => { + const response = await list(`?${query}`); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ error: message }); + expect(mockStore.list).not.toHaveBeenCalled(); + }); + + it("reports the limit before the cursor when both are invalid", async () => { + const response = await list("?limit=0&cursor=bad"); + + await expect(response.json()).resolves.toEqual({ error: "Invalid limit" }); + }); +}); diff --git a/packages/control-plane/src/routes/audit-events.ts b/packages/control-plane/src/routes/audit-events.ts index b1e78f039..8aa46e4b2 100644 --- a/packages/control-plane/src/routes/audit-events.ts +++ b/packages/control-plane/src/routes/audit-events.ts @@ -1,18 +1,36 @@ import { Hono } from "hono"; +import { z } from "zod"; import { encodeAuditEventCursor, parseAuditEventCursor } from "../db/audit-event-cursor"; import { AuditEventStore, toAuditEvent } from "../db/audit-event-store"; import { admit } from "../routing/admit"; import type { ControlPlaneHonoEnv } from "../routing/hono-env"; -import { error, json, requirePermission, SCM_AGNOSTIC_HUMAN_USER_ROUTE } from "./shared"; +import { parseQuery } from "./query"; +import { json, requirePermission, SCM_AGNOSTIC_HUMAN_USER_ROUTE } from "./shared"; -const DEFAULT_AUDIT_EVENT_LIMIT = 25; +export const DEFAULT_AUDIT_EVENT_LIMIT = 25; const MAX_AUDIT_EVENT_LIMIT = 100; -function singleQueryValue(searchParams: URLSearchParams, name: string): string | null | Response { - const values = searchParams.getAll(name); - if (values.length > 1) return error(`Invalid ${name}`, 400); - return values[0] ?? null; -} +const auditEventQuery = z.object({ + limit: z + .string({ error: "Invalid limit" }) + .regex(/^[1-9]\d*$/, { error: "Invalid limit" }) + .optional() + .transform((raw) => (raw === undefined ? DEFAULT_AUDIT_EVENT_LIMIT : Number(raw))) + .refine((limit) => Number.isSafeInteger(limit) && limit <= MAX_AUDIT_EVENT_LIMIT, { + error: "Invalid limit", + }), + cursor: z + .string() + .optional() + .transform((raw, ctx) => { + const parsed = parseAuditEventCursor(raw ?? null); + if (!parsed.ok) { + ctx.addIssue({ code: "custom", message: parsed.error }); + return z.NEVER; + } + return parsed.cursor; + }), +}); export const auditEventRoutes = new Hono(); @@ -25,21 +43,10 @@ auditEventRoutes.get( }), async (c) => { const { request, ctx } = c.var.admitted; - const searchParams = new URL(request.url).searchParams; - const rawLimit = singleQueryValue(searchParams, "limit"); - if (rawLimit instanceof Response) return rawLimit; - const cursor = singleQueryValue(searchParams, "cursor"); - if (cursor instanceof Response) return cursor; + const query = parseQuery(request, auditEventQuery); + if (query instanceof Response) return query; - if (rawLimit !== null && !/^[1-9]\d*$/.test(rawLimit)) return error("Invalid limit", 400); - const limit = rawLimit === null ? DEFAULT_AUDIT_EVENT_LIMIT : Number(rawLimit); - if (!Number.isSafeInteger(limit) || limit > MAX_AUDIT_EVENT_LIMIT) { - return error("Invalid limit", 400); - } - const parsedCursor = parseAuditEventCursor(cursor); - if (!parsedCursor.ok) return error(parsedCursor.error, 400); - - const result = await new AuditEventStore(ctx.db).list({ limit, cursor: parsedCursor.cursor }); + const result = await new AuditEventStore(ctx.db).list(query); return json({ events: result.rows.map(toAuditEvent), hasMore: result.hasMore, diff --git a/packages/control-plane/src/routes/automations.test.ts b/packages/control-plane/src/routes/automations.test.ts index 118922d5a..3e84c37dd 100644 --- a/packages/control-plane/src/routes/automations.test.ts +++ b/packages/control-plane/src/routes/automations.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type * as AuthenticateModule from "../auth/authenticate"; -import { automationRoutes } from "./automations"; +import { automationRoutes, MAX_NAME_LENGTH } from "./automations"; import { HttpError, resolveRepoOrError } from "./shared"; import type { Principal } from "../auth/principal"; import type { SqlDatabase, SqlStatement } from "../db/sql-database"; @@ -360,6 +360,21 @@ describe("automation route handlers", () => { expect(body.automations[0]).toMatchObject({ recentExecutions: [] }); }); + it.each<{ query: Record; error: string }>([ + { query: { limit: "0" }, error: "Invalid limit" }, + { query: { limit: "abc" }, error: "Invalid limit" }, + { query: { limit: "101" }, error: "Invalid limit" }, + { query: { limit: ["5", "6"] }, error: "Invalid limit" }, + { query: { cursor: "not-a-cursor" }, error: "Invalid cursor" }, + { query: { search: "x".repeat(MAX_NAME_LENGTH + 1) }, error: "Search is too long" }, + ])("rejects list query $query without listing", async ({ query, error }) => { + const res = await callRoute("GET", "/automations", { query }); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error }); + expect(mockStore.list).not.toHaveBeenCalled(); + }); + it("passes name search and pagination params to the store", async () => { mockStore.list.mockResolvedValue({ automations: [], hasMore: false, nextCursor: null }); diff --git a/packages/control-plane/src/routes/automations.ts b/packages/control-plane/src/routes/automations.ts index 3993b4494..1d9d0ac52 100644 --- a/packages/control-plane/src/routes/automations.ts +++ b/packages/control-plane/src/routes/automations.ts @@ -35,7 +35,6 @@ import { import { encodeAutomationListCursor, parseAutomationListCursor, - type AutomationListCursor, } from "../db/automation-list-cursor"; import { EnvironmentStore } from "../db/environments"; import { SlackChannelStore } from "../db/slack-channel-store"; @@ -73,6 +72,7 @@ import { requirePermission, type AutomationRouteAdmission, } from "./shared"; +import { parseQuery } from "./query"; import type { Env } from "../types"; import type { SqlDatabase, SqlStatement } from "../db/sql-database"; import { z } from "zod"; @@ -109,7 +109,7 @@ function admittedAutomation(ctx: RequestContext): AutomationRouteAdmission { const MIN_CRON_INTERVAL_MINUTES = 15; /** Maximum name length. */ -const MAX_NAME_LENGTH = 200; +export const MAX_NAME_LENGTH = 200; /** Maximum instructions length. Keep in sync with INSTRUCTIONS_MAX_LENGTH in packages/web/src/components/automations/automation-form.tsx. */ const MAX_INSTRUCTIONS_LENGTH = 15_000; @@ -424,87 +424,42 @@ const automationListLimitSchema = z }); const automationListQuerySchema = z.object({ - limit: automationListLimitSchema.optional(), - cursor: z.string().optional(), + limit: automationListLimitSchema + .optional() + .transform((limit) => limit ?? DEFAULT_AUTOMATION_LIST_PAGE_SIZE), + cursor: z + .string() + .optional() + .transform((raw, context) => { + const parsed = parseAutomationListCursor(raw ?? null); + if (!parsed.ok) { + context.addIssue({ code: "custom", message: parsed.error }); + return z.NEVER; + } + return parsed.cursor; + }), search: z.string().trim().max(MAX_NAME_LENGTH, { message: "Search is too long" }).optional(), repoOwner: z.string().optional(), repoName: z.string().optional(), }); -type AutomationListQueryParamName = keyof z.input; - -const AUTOMATION_LIST_QUERY_PARAM_NAMES = Object.keys( - automationListQuerySchema.shape -) as AutomationListQueryParamName[]; - -type ReadAutomationListQueryResult = - | { ok: true; query: Partial> } - | { ok: false; error: string }; - -function readAutomationListQuery(searchParams: URLSearchParams): ReadAutomationListQueryResult { - const query: Partial> = {}; - for (const name of AUTOMATION_LIST_QUERY_PARAM_NAMES) { - const values = searchParams.getAll(name); - if (values.length > 1) return { ok: false, error: `Invalid ${name}` }; - if (values.length === 1) query[name] = values[0]; - } - return { ok: true, query }; -} - -type ParseAutomationListParamsResult = - | { - ok: true; - options: { - limit: number; - cursor: AutomationListCursor | null; - nameSearch?: string; - repoOwner?: string; - repoName?: string; - }; - } - | { ok: false; error: string }; - -function parseAutomationListParams(request: Request): ParseAutomationListParamsResult { - const url = new URL(request.url); - const rawQuery = readAutomationListQuery(url.searchParams); - if (!rawQuery.ok) return rawQuery; - - const parsedQuery = automationListQuerySchema.safeParse(rawQuery.query); - if (!parsedQuery.success) { - return { - ok: false, - error: parsedQuery.error.issues[0]?.message ?? "Invalid automation list query", - }; - } - const parsedCursor = parseAutomationListCursor(parsedQuery.data.cursor ?? null); - if (!parsedCursor.ok) return parsedCursor; - - const { repoOwner, repoName } = parsedQuery.data; - const nameSearch = parsedQuery.data.search; - - return { - ok: true, - options: { - limit: parsedQuery.data.limit ?? DEFAULT_AUTOMATION_LIST_PAGE_SIZE, - cursor: parsedCursor.cursor, - ...(nameSearch ? { nameSearch } : {}), - ...(repoOwner ? { repoOwner } : {}), - ...(repoName ? { repoName } : {}), - }, - }; -} - async function handleListAutomations( request: Request, env: Env, ctx: RequestContext ): Promise { - const parsed = parseAutomationListParams(request); - if (!parsed.ok) return error(parsed.error, 400); + const query = parseQuery(request, automationListQuerySchema); + if (query instanceof Response) return query; const store = new AutomationStore(ctx.db); const providerAuthStore = new AutomationModelProviderAuthStore(ctx.db); - const result = await store.list(parsed.options); + const result = await store.list({ + limit: query.limit, + cursor: query.cursor, + ...(query.search ? { nameSearch: query.search } : {}), + ...(query.repoOwner ? { repoOwner: query.repoOwner } : {}), + ...(query.repoName ? { repoName: query.repoName } : {}), + }); const automationIds = result.automations.map((row) => row.id); const [ repositoriesByAutomation, diff --git a/packages/control-plane/src/routes/query.test.ts b/packages/control-plane/src/routes/query.test.ts new file mode 100644 index 000000000..f0e972c30 --- /dev/null +++ b/packages/control-plane/src/routes/query.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { parseQuery } from "./query"; + +const schema = z.object({ + limit: z + .string() + .regex(/^[1-9]\d*$/, { error: "Invalid limit" }) + .optional(), + by: z.enum(["user", "repo"], { error: "by must be one of: user, repo" }), +}); + +function request(query: string): Request { + return new Request(`https://test.local/things?${query}`); +} + +async function rejection(result: unknown): Promise<{ status: number; body: unknown }> { + expect(result).toBeInstanceOf(Response); + const response = result as Response; + return { status: response.status, body: await response.json() }; +} + +describe("parseQuery", () => { + it("returns the parsed values for the keys the schema declares", () => { + expect(parseQuery(request("by=repo&limit=5&unrelated=1"), schema)).toEqual({ + by: "repo", + limit: "5", + }); + }); + + it("refuses a declared key given more than once before the schema runs", async () => { + await expect(rejection(parseQuery(request("by=repo&by=user"), schema))).resolves.toEqual({ + status: 400, + body: { error: "Invalid by" }, + }); + }); + + it("ignores repeats of keys the schema does not declare", () => { + expect(parseQuery(request("by=user&unrelated=1&unrelated=2"), schema)).toEqual({ + by: "user", + }); + }); + + it("answers the first schema issue with its own message", async () => { + await expect(rejection(parseQuery(request("limit=0&by=nope"), schema))).resolves.toEqual({ + status: 400, + body: { error: "Invalid limit" }, + }); + await expect(rejection(parseQuery(request("limit=1"), schema))).resolves.toEqual({ + status: 400, + body: { error: "by must be one of: user, repo" }, + }); + }); +}); diff --git a/packages/control-plane/src/routes/query.ts b/packages/control-plane/src/routes/query.ts new file mode 100644 index 000000000..95ae4a14b --- /dev/null +++ b/packages/control-plane/src/routes/query.ts @@ -0,0 +1,25 @@ +import type { z } from "zod"; +import { error } from "../http/responses"; + +/** + * Parse a request's query string with `schema`, or answer the route's 400. + * + * Only the keys the schema declares are read. A key given more than once is + * refused as `Invalid ` before the schema sees it, and a schema failure + * answers its first issue's message, so each route keeps its own wording. + */ +export function parseQuery( + request: Request, + schema: z.ZodObject +): z.output> | Response { + const searchParams = new URL(request.url).searchParams; + const input: Record = {}; + for (const key of Object.keys(schema.shape)) { + const values = searchParams.getAll(key); + if (values.length > 1) return error(`Invalid ${key}`, 400); + if (values.length === 1) input[key] = values[0]; + } + const result = schema.safeParse(input); + if (!result.success) return error(result.error.issues[0]?.message ?? "Invalid query", 400); + return result.data; +}