diff --git a/README.md b/README.md index 379ff03..bdf2891 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,13 @@ Backend endpoints are taken from the companion documentation page `src/app/docs/ | `/usage` | Usage totals & settlement workflow | `POST /api/v1/usage`, `GET /api/v1/usage/:agent/:serviceId`, `POST /api/v1/settle` | | `/webhooks` | Webhooks management | _(calls webhooks endpoints in code)_ and displays each webhook registration time relatively with an absolute timestamp tooltip | +### Usage identifier validation + +The `/usage` record and query forms trim the agent and service identifiers before +submission. Identifiers must be 1-128 characters and may only contain letters, +numbers, dots, underscores, hyphens, and colons. Query requests still URL-encode +the validated identifiers before placing them in the path. + ### API keys page notes The `/api-keys` page lists each key label, prefix, and created-at age with the absolute ISO timestamp available on hover. If the account has no keys, the page renders a clear "No API keys yet" empty state instead of an empty list while preserving the create, reveal-once, copy, and revoke confirmation flows. diff --git a/src/app/usage/page.test.tsx b/src/app/usage/page.test.tsx index a85d952..45baf0a 100644 --- a/src/app/usage/page.test.tsx +++ b/src/app/usage/page.test.tsx @@ -19,13 +19,13 @@ describe("UsagePage", () => { it("renders both Record and Query landmarks", () => { render(); expect( - screen.getByRole("heading", { name: /Usage metering/i }) + screen.getByRole("heading", { name: /Usage metering/i }), ).toBeInTheDocument(); expect( - screen.getByRole("heading", { name: /Record usage/i }) + screen.getByRole("heading", { name: /Record usage/i }), ).toBeInTheDocument(); expect( - screen.getByRole("heading", { name: /Query usage/i }) + screen.getByRole("heading", { name: /Query usage/i }), ).toBeInTheDocument(); }); @@ -34,10 +34,10 @@ describe("UsagePage", () => { render(); fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[0], { - target: { value: "a" }, + target: { value: " agent-1 " }, }); fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[0], { - target: { value: "s" }, + target: { value: " svc.alpha:prod " }, }); fireEvent.change(screen.getByLabelText(/^Requests$/i), { target: { value: "42" }, @@ -48,12 +48,67 @@ describe("UsagePage", () => { expect(screen.getByRole("status")).toHaveTextContent(/New total: 42/); }); expect(apiPostMock).toHaveBeenCalledWith("/api/v1/usage", { - agent: "a", - serviceId: "s", + agent: "agent-1", + serviceId: "svc.alpha:prod", requests: 42, }); }); + it("blocks record submit for empty, whitespace, or malformed identifiers", async () => { + render(); + const agentInput = screen.getAllByLabelText(/^Agent$/i)[0]; + const serviceInput = screen.getAllByLabelText(/^Service ID$/i)[0]; + + fireEvent.change(agentInput, { target: { value: " " } }); + fireEvent.change(serviceInput, { target: { value: "svc/one" } }); + fireEvent.change(screen.getByLabelText(/^Requests$/i), { + target: { value: "1" }, + }); + fireEvent.submit(screen.getByLabelText(/^Requests$/i).closest("form")!); + + await waitFor(() => { + expect(agentInput).toHaveAttribute("aria-invalid", "true"); + expect(serviceInput).toHaveAttribute("aria-invalid", "true"); + }); + expect(screen.getByText("Agent is required.")).toBeInTheDocument(); + expect( + screen.getByText( + "Service ID can only use letters, numbers, dots, underscores, hyphens, and colons.", + ), + ).toBeInTheDocument(); + expect(agentInput.getAttribute("aria-describedby")).toBeTruthy(); + expect(serviceInput.getAttribute("aria-describedby")).toBeTruthy(); + expect(apiPostMock).not.toHaveBeenCalled(); + }); + + it("clears record identifier field errors after editing", async () => { + render(); + const agentInput = screen.getAllByLabelText(/^Agent$/i)[0]; + + fireEvent.change(agentInput, { target: { value: "agent one" } }); + fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[0], { + target: { value: "svc-1" }, + }); + fireEvent.change(screen.getByLabelText(/^Requests$/i), { + target: { value: "1" }, + }); + fireEvent.submit(screen.getByLabelText(/^Requests$/i).closest("form")!); + + expect( + await screen.findByText( + "Agent can only use letters, numbers, dots, underscores, hyphens, and colons.", + ), + ).toBeInTheDocument(); + + fireEvent.change(agentInput, { target: { value: "agent-one" } }); + expect( + screen.queryByText( + "Agent can only use letters, numbers, dots, underscores, hyphens, and colons.", + ), + ).not.toBeInTheDocument(); + expect(agentInput).toHaveAttribute("aria-invalid", "false"); + }); + it("surfaces a backend invalid_request as a role=alert", async () => { apiPostMock.mockRejectedValueOnce({ error: "invalid_request", @@ -94,7 +149,7 @@ describe("UsagePage", () => { await waitFor(() => { expect(screen.getByRole("alert")).toHaveTextContent( - /requests must be a positive integer/ + /requests must be a positive integer/, ); }); expect(apiPostMock).not.toHaveBeenCalled(); @@ -109,19 +164,44 @@ describe("UsagePage", () => { render(); fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { - target: { value: "a" }, - }); - fireEvent.change(screen.getByLabelText(/^Service ID$/i, { selector: 'input[name="queryServiceId"]' }), { - target: { value: "s" }, - }); + target: { value: " a " }, + }); + fireEvent.change( + screen.getByLabelText(/^Service ID$/i, { + selector: 'input[name="queryServiceId"]', + }), + { + target: { value: " s " }, + }, + ); fireEvent.click(screen.getByRole("button", { name: /Query/i })); await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent(/a \/ s: 12 request\(s\)\./i); + expect(screen.getByRole("status")).toHaveTextContent( + /a \/ s: 12 request\(s\)\./i, + ); }); expect(apiGetMock).toHaveBeenCalledWith("/api/v1/usage/a/s"); }); + it("blocks query submit for too-long identifiers", async () => { + render(); + const queryAgentInput = screen.getAllByLabelText(/^Agent$/i)[1]; + const queryServiceInput = screen.getAllByLabelText(/^Service ID$/i)[1]; + + fireEvent.change(queryAgentInput, { target: { value: "agent-ok" } }); + fireEvent.change(queryServiceInput, { target: { value: "s".repeat(129) } }); + fireEvent.click(screen.getByRole("button", { name: /Query/i })); + + await waitFor(() => { + expect(queryServiceInput).toHaveAttribute("aria-invalid", "true"); + }); + expect( + screen.getByText("Service ID must be 128 characters or fewer."), + ).toBeInTheDocument(); + expect(apiGetMock).not.toHaveBeenCalled(); + }); + it("shows a request id when the query request fails", async () => { apiGetMock.mockRejectedValueOnce({ error: "invalid_request", @@ -133,9 +213,14 @@ describe("UsagePage", () => { fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { target: { value: "a" }, }); - fireEvent.change(screen.getByLabelText(/^Service ID$/i, { selector: 'input[name="queryServiceId"]' }), { - target: { value: "s" }, - }); + fireEvent.change( + screen.getByLabelText(/^Service ID$/i, { + selector: 'input[name="queryServiceId"]', + }), + { + target: { value: "s" }, + }, + ); fireEvent.click(screen.getByRole("button", { name: /Query/i })); await waitFor(() => { @@ -195,9 +280,13 @@ describe("UsagePage", () => { render(); // First query - fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { target: { value: "a" } }); - fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[1], { target: { value: "s" } }); - + fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { + target: { value: "a" }, + }); + fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[1], { + target: { value: "s" }, + }); + const queryButton = screen.getByRole("button", { name: /Query/i }); fireEvent.click(queryButton); @@ -213,12 +302,16 @@ describe("UsagePage", () => { }); await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent(/a \/ s: 10 request\(s\)/i); + expect(screen.getByRole("status")).toHaveTextContent( + /a \/ s: 10 request\(s\)/i, + ); }); // Start second query - fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { target: { value: "b" } }); - + fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { + target: { value: "b" }, + }); + // Create new promise for second query let resolveQuery2: (value: unknown) => void; apiGetMock.mockImplementationOnce(() => { @@ -240,7 +333,9 @@ describe("UsagePage", () => { }); await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent(/b \/ s: 20 request\(s\)/i); + expect(screen.getByRole("status")).toHaveTextContent( + /b \/ s: 20 request\(s\)/i, + ); }); }); @@ -249,12 +344,18 @@ describe("UsagePage", () => { apiGetMock.mockResolvedValueOnce({ agent: "a", serviceId: "s", total: 10 }); - fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { target: { value: "a" } }); - fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[1], { target: { value: "s" } }); + fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { + target: { value: "a" }, + }); + fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[1], { + target: { value: "s" }, + }); fireEvent.click(screen.getByRole("button", { name: /Query/i })); await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent(/a \/ s: 10 request\(s\)/i); + expect(screen.getByRole("status")).toHaveTextContent( + /a \/ s: 10 request\(s\)/i, + ); }); // Now error @@ -270,15 +371,25 @@ describe("UsagePage", () => { }); it("handles an empty/zero total", async () => { - apiGetMock.mockResolvedValueOnce({ agent: "zero", serviceId: "s", total: 0 }); + apiGetMock.mockResolvedValueOnce({ + agent: "zero", + serviceId: "s", + total: 0, + }); render(); - fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { target: { value: "zero" } }); - fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[1], { target: { value: "s" } }); + fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[1], { + target: { value: "zero" }, + }); + fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[1], { + target: { value: "s" }, + }); fireEvent.click(screen.getByRole("button", { name: /Query/i })); await waitFor(() => { - expect(screen.getByRole("status")).toHaveTextContent(/zero \/ s: 0 request\(s\)/i); + expect(screen.getByRole("status")).toHaveTextContent( + /zero \/ s: 0 request\(s\)/i, + ); }); }); @@ -291,12 +402,18 @@ describe("UsagePage", () => { }); render(); - fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[0], { target: { value: "a" } }); - fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[0], { target: { value: "s" } }); - fireEvent.change(screen.getByLabelText(/^Requests$/i), { target: { value: "5" } }); - + fireEvent.change(screen.getAllByLabelText(/^Agent$/i)[0], { + target: { value: "a" }, + }); + fireEvent.change(screen.getAllByLabelText(/^Service ID$/i)[0], { + target: { value: "s" }, + }); + fireEvent.change(screen.getByLabelText(/^Requests$/i), { + target: { value: "5" }, + }); + const recordButton = screen.getByRole("button", { name: /Record/i }); - + // Using submit to verify the onRecord handler prevents multiple calls const form = screen.getByLabelText(/^Requests$/i).closest("form")!; fireEvent.submit(form); diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 9c52f79..19030e7 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -7,6 +7,7 @@ import { apiGet, apiPost } from "@/lib/apiClient"; import type { FormEvent } from "react"; import { useState } from "react"; import { parsePositiveInt } from "@/lib/validateNumber"; +import { validateIdentifier } from "@/lib/validateId"; type QueryResult = { agent: string; @@ -26,7 +27,10 @@ type QueryStatus = | { kind: "ok"; result: QueryResult | null } | { kind: "error"; message: string; requestId?: string }; -function describeError(error: unknown): { message: string; requestId?: string } { +function describeError(error: unknown): { + message: string; + requestId?: string; +} { const apiError = error as Partial | null | undefined; return { message: @@ -48,12 +52,18 @@ function formatAlert(message: string, requestId?: string): string { export default function UsagePage() { const [agent, setAgent] = useState(""); + const [agentError, setAgentError] = useState(null); const [serviceId, setServiceId] = useState(""); + const [serviceIdError, setServiceIdError] = useState(null); const [requests, setRequests] = useState(""); const [requestsError, setRequestsError] = useState(null); const [status, setStatus] = useState({ kind: "idle" }); const [queryAgent, setQueryAgent] = useState(""); + const [queryAgentError, setQueryAgentError] = useState(null); const [queryService, setQueryService] = useState(""); + const [queryServiceError, setQueryServiceError] = useState( + null, + ); const [queryResult, setQueryResult] = useState({ kind: "idle" }); const isRecording = status.kind === "loading"; const isQuerying = queryResult.kind === "loading"; @@ -61,7 +71,16 @@ export default function UsagePage() { const onRecord = async (event: FormEvent) => { event.preventDefault(); if (isRecording) return; + setAgentError(null); + setServiceIdError(null); setRequestsError(null); + const parsedAgent = validateIdentifier(agent, "Agent"); + const parsedServiceId = validateIdentifier(serviceId, "Service ID"); + if (!parsedAgent.ok || !parsedServiceId.ok) { + if (!parsedAgent.ok) setAgentError(parsedAgent.message); + if (!parsedServiceId.ok) setServiceIdError(parsedServiceId.message); + return; + } const parsed = parsePositiveInt(requests); if (!parsed.ok) { // Surface the validation message through the field error. @@ -72,8 +91,8 @@ export default function UsagePage() { setStatus({ kind: "loading" }); try { const body = await apiPost<{ total: number }>("/api/v1/usage", { - agent, - serviceId, + agent: parsedAgent.value, + serviceId: parsedServiceId.value, requests: parsed.value, }); setStatus({ kind: "ok", total: body?.total }); @@ -86,13 +105,22 @@ export default function UsagePage() { const onQuery = async (event: FormEvent) => { event.preventDefault(); if (isQuerying) return; + setQueryAgentError(null); + setQueryServiceError(null); + const parsedAgent = validateIdentifier(queryAgent, "Agent"); + const parsedServiceId = validateIdentifier(queryService, "Service ID"); + if (!parsedAgent.ok || !parsedServiceId.ok) { + if (!parsedAgent.ok) setQueryAgentError(parsedAgent.message); + if (!parsedServiceId.ok) setQueryServiceError(parsedServiceId.message); + return; + } setQueryResult({ kind: "loading" }); try { const result = await apiGet( - `/api/v1/usage/${encodeURIComponent(queryAgent)}/${encodeURIComponent( - queryService - )}` + `/api/v1/usage/${encodeURIComponent(parsedAgent.value)}/${encodeURIComponent( + parsedServiceId.value, + )}`, ); setQueryResult({ kind: "ok", result: result ?? null }); } catch (error) { @@ -108,7 +136,9 @@ export default function UsagePage() { className="mx-auto flex min-h-screen max-w-2xl flex-col gap-12 p-8 focus:outline-none" >
-

Usage metering

+

+ Usage metering +

Record per-request usage for an agent and query the running total.

@@ -119,26 +149,28 @@ export default function UsagePage() { Record usage
- - + { + setAgent(e.target.value); + setAgentError(null); + }} + error={agentError ?? undefined} + /> + { + setServiceId(e.target.value); + setServiceIdError(null); + }} + error={serviceIdError ?? undefined} + /> {status.kind === "ok" && ( -

+

{typeof status.total === "number" ? `Recorded. New total: ${status.total}.` : "Recorded."} @@ -181,26 +216,28 @@ export default function UsagePage() { Query usage

- - + { + setQueryAgent(e.target.value); + setQueryAgentError(null); + }} + error={queryAgentError ?? undefined} + /> + { + setQueryService(e.target.value); + setQueryServiceError(null); + }} + error={queryServiceError ?? undefined} + />