diff --git a/README.md b/README.md index bfe26e4..386d77e 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ Plexara MCP exposes two gateway capabilities: `api-test` is the upstream HTTP fixture the API gateway calls. Endpoints are deliberately simple and deterministic; their job is not to compute anything useful, it's to make the gateway's behavior observable. Every -request will (M2+) be recorded in a Postgres-backed audit log so you can +request will be recorded in a Postgres-backed audit log so you can compare what a client sent through Plexara, what reached this server, and what came back. -## Endpoint groups (M1) +## Endpoint groups - **identity** — `GET /v1/whoami`, `GET /v1/headers`. Verify the gateway forwards identity, args, and HTTP headers (with redaction). @@ -61,7 +61,7 @@ make test # alias: go test -race -count=1 ./... make verify # CI-equivalent: fmt, vet, test, lint, security, coverage gate ``` -Integration tests requiring testcontainers Postgres land in M2. +Integration tests requiring testcontainers Postgres land in. ## Layout diff --git a/configs/api-test.dev.yaml b/configs/api-test.dev.yaml index f9c24a2..b850db3 100644 --- a/configs/api-test.dev.yaml +++ b/configs/api-test.dev.yaml @@ -23,10 +23,8 @@ endpoints: data: { enabled: true } failure: { enabled: true } echo: { enabled: true } - # The groups below land in M3/M4; toggles are accepted now but the - # underlying groups aren't registered until those milestones. - streaming: { enabled: false } - pagination: { enabled: false } - methods: { enabled: false } - security: { enabled: false } - export: { enabled: false } + streaming: { enabled: true } + pagination: { enabled: true } + methods: { enabled: true } + security: { enabled: true } + export: { enabled: true } diff --git a/configs/api-test.example.yaml b/configs/api-test.example.yaml index c1945b7..e6dad98 100644 --- a/configs/api-test.example.yaml +++ b/configs/api-test.example.yaml @@ -71,11 +71,11 @@ endpoints: data: { enabled: true } failure: { enabled: true } echo: { enabled: true } - streaming: { enabled: true } # M3+ - pagination: { enabled: true } # M4+ - methods: { enabled: true } # M4+ - security: { enabled: true } # M4+ - export: { enabled: true } # M4+ + streaming: { enabled: true } + pagination: { enabled: true } + methods: { enabled: true } + security: { enabled: true } + export: { enabled: true } # Optional: POST a connection definition to a Plexara admin URL on boot # so api-test self-registers in dev. Default off; keep fixture decoupled. diff --git a/configs/api-test.live.yaml b/configs/api-test.live.yaml index fbd207e..25749a7 100644 --- a/configs/api-test.live.yaml +++ b/configs/api-test.live.yaml @@ -56,8 +56,8 @@ endpoints: data: { enabled: true } failure: { enabled: true } echo: { enabled: true } - streaming: { enabled: false } - pagination: { enabled: false } - methods: { enabled: false } - security: { enabled: false } - export: { enabled: false } + streaming: { enabled: true } + pagination: { enabled: true } + methods: { enabled: true } + security: { enabled: true } + export: { enabled: true } diff --git a/docs/configuration/auth.md b/docs/configuration/auth.md index f73c38d..89b368c 100644 --- a/docs/configuration/auth.md +++ b/docs/configuration/auth.md @@ -13,7 +13,7 @@ gateway forwards from its caller. The chain is: if enabled, the bcrypt-hashed Postgres store. 2. **Static bearer** — `Authorization: Bearer ` matched against the `bearer.tokens` static list. -3. **OIDC JWT** (M3+) — `Authorization: Bearer ` validated against +3. **OIDC JWT** — `Authorization: Bearer ` validated against the configured IdP's JWKS. 4. **Anonymous fallback** — when `auth.allow_anonymous: true` and no credential matched, requests proceed with an anonymous identity. @@ -59,7 +59,7 @@ database: ``` The bcrypt store layers under the file store: file keys win, DB keys -are consulted on miss. To create a key, use the portal (M3+) or call +are consulted on miss. To create a key, use the portal or call the admin API directly: ```bash @@ -87,7 +87,7 @@ bearer: Used when a Plexara connection is configured with `auth_mode: bearer` and `credential: `. -## OIDC JWT (M3+) +## OIDC JWT When the Plexara gateway uses `oauth2_client_credentials` or `oauth2_authorization_code`, it exchanges with the IdP and forwards the @@ -112,7 +112,7 @@ JWKS is cached in-process for `jwks_cache_ttl`. Validation checks: - `azp` (or `client_id` claim, depending on IdP) is in `allowed_clients`. - `exp` and `nbf` allow a `clock_skew_seconds` tolerance. -The Keycloak realm `dev/keycloak/api-test-realm.json` (M3) pre-seeds +The Keycloak realm `dev/keycloak/api-test-realm.json` pre-seeds two confidential clients (`plexara-cc` for client-credentials, `plexara-ac` for auth-code) and a portal user (`dev` / `dev`). @@ -153,7 +153,7 @@ safe to run with anonymous + a few static keys: clients that send a valid key get their identity, clients that send nothing get anonymous, clients that send a bad key get 401. -## Portal browser login (M3+) +## Portal browser login The portal uses a standard OIDC PKCE flow: hit `/portal/`, redirect to the IdP, callback at `portal.oidc_redirect_path`, set a session cookie. diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 780a2cd..708fcb1 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -39,7 +39,7 @@ first run. Subsequent runs reuse the file so sessions persist. `source ./.env.dev` to load them into the current shell. -## OIDC (M3+) +## OIDC | Variable | Used in | Description | | --- | --- | --- | diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 0b7ec6c..19e089c 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -60,7 +60,7 @@ Static bearer-token authentication for `auth_mode: bearer` connections. External OIDC IdP for JWT validation (`oauth2_client_credentials` and `oauth2_authorization_code` Plexara connections; also the portal browser -login). Lands in M3 alongside Keycloak. +login).. | Key | Default | Description | | --- | --- | --- | @@ -120,7 +120,7 @@ Default `redact_keys`: ## `portal` -The embedded React SPA + portal API. Lands in M3. +The embedded React SPA + portal API.. | Key | Default | Description | | --- | --- | --- | @@ -135,17 +135,17 @@ The embedded React SPA + portal API. Lands in M3. Per-group toggles. Disabling a group removes its routes from the mux and from the published OpenAPI doc. -| Key | Default | Status | -| --- | --- | --- | -| `identity.enabled` | `false` | M1 | -| `data.enabled` | `false` | M1 | -| `failure.enabled` | `false` | M1 | -| `echo.enabled` | `false` | M1 | -| `streaming.enabled` | `false` | M3+ | -| `pagination.enabled` | `false` | M4+ | -| `methods.enabled` | `false` | M4+ | -| `security.enabled` | `false` | M4+ | -| `export.enabled` | `false` | M4+ | +| Key | Default | +| --- | --- | +| `identity.enabled` | `false` | +| `data.enabled` | `false` | +| `failure.enabled` | `false` | +| `echo.enabled` | `false` | +| `streaming.enabled` | `false` | +| `pagination.enabled` | `false` | +| `methods.enabled` | `false` | +| `security.enabled` | `false` | +| `export.enabled` | `false` | ## `plexara.register` diff --git a/docs/endpoints/data.md b/docs/endpoints/data.md index af15cbf..992deae 100644 --- a/docs/endpoints/data.md +++ b/docs/endpoints/data.md @@ -67,7 +67,7 @@ Response (200): Bounds: - `0 <= bytes <= 32 MiB` (32×1024×1024). Larger sizes belong on the - export endpoint group (M4+), which streams to the asset store + export endpoint group, which streams to the asset store instead of allocating in memory. - `bytes < 0` or non-integer → 400. diff --git a/docs/endpoints/echo.md b/docs/endpoints/echo.md index a2ac35b..ba29980 100644 --- a/docs/endpoints/echo.md +++ b/docs/endpoints/echo.md @@ -105,4 +105,4 @@ curl -s -I http://localhost:8080/v1/echo -H "X-API-Key: $KEY" The handler reads up to 1 MiB of inbound body. Larger bodies are truncated; `body_size` reports the captured prefix length. For testing the gateway's handling of >1 MiB bodies, use the export -endpoint group (M4+). +endpoint group. diff --git a/docs/endpoints/export.md b/docs/endpoints/export.md new file mode 100644 index 0000000..249443b --- /dev/null +++ b/docs/endpoints/export.md @@ -0,0 +1,68 @@ +--- +title: Export +description: Large/long-running endpoints intended as targets for the Plexara API gateway's api_export tool. Exercise the gateway's body-size and slow-first-byte handling. +--- + +# Export + +Three endpoints designed to stress the gateway differently: + +| Method | Path | What it stresses | +| --- | --- | --- | +| `GET` | `/v1/export/big-body?size_kb=N&seed=S` | Large response body forwarding. | +| `GET` | `/v1/export/csv?rows=N&seed=S` | Non-JSON content type, large text. | +| `GET` | `/v1/export/long-running?duration_ms=N` | Slow first-byte. | + +## Determinism + +`big-body` and `csv` both fill their rows with +`hex(sha256(seed:index))[:16]`. Same `(seed, index)` produces the same +value across runs, across endpoints, and across builds. The same +fixture data shows up in the `pagination` group too, by design. + +## Bounds + +| Parameter | Default | Max | +| --- | --- | --- | +| `size_kb` | 64 | 10240 (10 MiB) | +| `rows` | 1000 | 250000 | +| `duration_ms` | 1000 | 60000 (60 s) | + +Values outside the bounds return `400`. The caps are fixture-side +defense against runaway test invocations, not a contract — real +`api_export` traffic moves well above these in production. + +## Context cancellation + +Both `/v1/export/big-body` and `/v1/export/csv` check +`r.Context().Done()` between rows and stop writing when the client +disconnects. `/v1/export/long-running` uses a `select` on a timer +plus the context, so a 60-second wait aborts instantly on disconnect +instead of pinning a goroutine. + +## Examples + +```bash +# 64 KiB JSON array (default size, with a fixed seed). +curl -s 'http://localhost:8080/v1/export/big-body?seed=fixed' | wc -c + +# 1000-row CSV. +curl -s 'http://localhost:8080/v1/export/csv?rows=1000&seed=fixed' | head -3 +# index,value +# 0, +# 1, + +# 5-second slow-first-byte. Useful for gateway request-timeout testing. +curl -s 'http://localhost:8080/v1/export/long-running?duration_ms=5000' +# {"slept_ms":5000} +``` + +## Why this exists + +The Plexara gateway's `api_export` tool streams large upstream responses +to an asset store rather than buffering them in memory. A misconfigured +gateway can break this in two ways: by truncating the body when its +in-memory cap fires (`big-body` exposes this), or by timing out the +upstream call before the first byte arrives (`long-running` exposes +this). The `csv` endpoint stresses the content-type code path that +`api_export` clients use when they ask for non-JSON exports. diff --git a/docs/endpoints/methods.md b/docs/endpoints/methods.md new file mode 100644 index 0000000..fc26d5b --- /dev/null +++ b/docs/endpoints/methods.md @@ -0,0 +1,46 @@ +--- +title: Methods +description: Method-matrix endpoint that accepts every common HTTP verb at a single path and echoes the verb the server observed. +--- + +# Methods + +A single path, every common verb. Lets a gateway test assert that the +HTTP method survives the proxy hop unchanged. + +| Method | Path | Returns | +| --- | --- | --- | +| `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS` | `/v1/method/echo` | `{ "method": "POST", "path": "/v1/method/echo", "query": {...} }` | + +`HEAD` returns headers only (per RFC 7231). `OPTIONS` returns the body +plus an `Allow` header listing every supported verb. + +`CONNECT` and `TRACE` are not registered; Go's `http.ServeMux` answers +them with `405 Method Not Allowed` because other verbs are registered +for the same path. + +## Examples + +```bash +curl -s -X PATCH http://localhost:8080/v1/method/echo +# {"method":"PATCH","path":"/v1/method/echo"} + +curl -is -X HEAD http://localhost:8080/v1/method/echo | head -1 +# HTTP/1.1 200 OK + +curl -is -X OPTIONS http://localhost:8080/v1/method/echo +# HTTP/1.1 200 OK +# Allow: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS + +curl -s -X CONNECT http://localhost:8080/v1/method/echo +# (405 Method Not Allowed) +``` + +## Why this exists + +Gateway proxies sometimes break verbs in subtle ways: rewriting `PATCH` +to `POST` to fit a stricter client library, swallowing `OPTIONS` +pre-flight responses inside a CORS layer, or refusing `HEAD` because +the upstream handler doesn't register it explicitly. This endpoint +exposes every verb at one path so a tester can spot any of those +rewrites with a single curl loop. diff --git a/docs/endpoints/overview.md b/docs/endpoints/overview.md index 263c306..ffafeb7 100644 --- a/docs/endpoints/overview.md +++ b/docs/endpoints/overview.md @@ -27,17 +27,17 @@ call, the body it got back is bit-for-bit predictable. ## Groups -| Group | Status | Purpose | -| --- | --- | --- | -| [Identity](identity.md) | M1 | Verify identity / header pass-through. | -| [Data](data.md) | M1 | Deterministic bodies for caching / dedup / size handling. | -| [Failure](failure.md) | M1 | Controlled error codes, latency, seeded flake. | -| [Echo](echo.md) | M1 | Generic catch-all that returns the request verbatim. | -| Streaming | M3+ | Chunked, SSE, NDJSON responses. | -| Pagination | M4+ | One endpoint per cursor style the gateway recognizes. | -| Methods | M4+ | Method matrix on `/v1/method/echo`. | -| Security | M4+ | Probe targets the gateway should refuse to forward. | -| Export | M4+ | Large/long-running targets exercising `api_export`. | +| Group | Purpose | +| --- | --- | +| [Identity](identity.md) | Verify identity / header pass-through. | +| [Data](data.md) | Deterministic bodies for caching / dedup / size handling. | +| [Failure](failure.md) | Controlled error codes, latency, seeded flake. | +| [Echo](echo.md) | Generic catch-all that returns the request verbatim. | +| Streaming | Chunked, SSE, NDJSON responses. | +| Pagination | One endpoint per cursor style the gateway recognizes. | +| Methods | Method matrix on `/v1/method/echo`. | +| Security | Probe targets the gateway should refuse to forward. | +| Export | Large/long-running targets exercising `api_export`. | ## Toggling groups @@ -60,7 +60,7 @@ chaos testing) and one with only `data` (for stable cache fixtures). ## OpenAPI exposure Every enabled route is published in `/openapi.json` and `/openapi.yaml` -(M4+). The Plexara gateway's `api_list_endpoints` tool reads this +. The Plexara gateway's `api_list_endpoints` tool reads this document, so registering api-test with its OpenAPI spec inline gives gateway callers a discoverable catalog. diff --git a/docs/endpoints/pagination.md b/docs/endpoints/pagination.md new file mode 100644 index 0000000..9294343 --- /dev/null +++ b/docs/endpoints/pagination.md @@ -0,0 +1,111 @@ +--- +title: Pagination +description: Three deterministic paginated endpoints, one per cursor style the Plexara API gateway recognizes. +--- + +# Pagination + +Three endpoints, one per pagination style commonly seen in REST APIs. +Each one slices the same synthetic dataset of `(id, value)` pairs, so +a gateway client can assert "the items I got from `$skip=20` match the +items I got from `?cursor=...` after walking the cursor 20 steps." + +| Method | Path | Style | Pagination signal | +| --- | --- | --- | --- | +| `GET` | `/v1/pagination/link` | RFC 5988 Link header | `Link: ; rel="next"` (also first, prev, last). | +| `GET` | `/v1/pagination/odata` | OData v4 | `@odata.nextLink` in the body. | +| `GET` | `/v1/pagination/cursor` | Opaque cursor | Base64 `next_cursor` field; clients pass it back verbatim. | + +## Shared item shape + +Every endpoint returns items of this shape: + +```json +{ "id": 7, "value": "7902699be42c8a8e" } +``` + +`value` is `hex(sha256(id)[:8])` — a stable function of `id` only. The +same id produces the same value across styles, builds, and process +restarts. This makes cross-style assertions falsifiable. + +## Synthetic dataset + +The dataset is just the integers `0` through `total-1` paired with +their `deterministicValue`. There is no real storage; the handlers +compute items on the fly. Default `total=100`; max `10000`. + +## `/v1/pagination/link` + +| Parameter | Default | Bounds | Description | +| --- | --- | --- | --- | +| `page` | `1` | `≥ 1` | 1-indexed page. | +| `per_page` | `10` | `1` to `1000` | Items per page. | +| `total` | `100` | `1` to `10000` | Synthetic dataset size. | + +Response body: + +```json +{ + "items": [...], + "page": 2, + "per_page": 10, + "total": 100 +} +``` + +`Link` header carries `first`, `last`, and conditionally `prev` and +`next` URLs (no `prev` on page 1; no `next` on the last page). +Requesting `page` past the end returns `400`. + +## `/v1/pagination/odata` + +| Parameter | Default | Bounds | Description | +| --- | --- | --- | --- | +| `$top` | `10` | `1` to `1000` | Items in this page. | +| `$skip` | `0` | `≥ 0` | Items to skip before this page. | +| `total` | `100` | `1` to `10000` | Synthetic dataset size. | + +Response body (OData v4 shape): + +```json +{ + "value": [...], + "@odata.count": 100, + "@odata.nextLink": "http://localhost:8080/v1/pagination/odata?$top=10&$skip=10&total=100" +} +``` + +`@odata.nextLink` is omitted on the last page. Requesting `$skip` past +the end returns `400`. + +## `/v1/pagination/cursor` + +| Parameter | Default | Bounds | Description | +| --- | --- | --- | --- | +| `cursor` | (empty) | base64 string | Opaque cursor from a prior response. | +| `limit` | `10` | `1` to `1000` | Items per page. | +| `total` | `100` | `1` to `10000` | Synthetic dataset size. | + +Response body: + +```json +{ + "items": [...], + "next_cursor": "MTA" +} +``` + +`next_cursor` is omitted on the last page. Clients should treat the +cursor as opaque — its current implementation is `base64url(offset)`, +but that shape is not part of the contract and may change. + +Malformed cursors return `400`. + +## Why this exists + +Gateway proxies can corrupt pagination three ways: by rewriting the +`Link` header host (breaking links that point back at the upstream), +by stripping unknown body fields like `@odata.nextLink` (breaking OData +clients), or by treating cursor values as opaque strings that need +re-encoding (breaking opaque cursors). These endpoints let you assert +end-to-end that all three signals survive the proxy hop bit-for-bit. diff --git a/docs/endpoints/security.md b/docs/endpoints/security.md new file mode 100644 index 0000000..8c49c56 --- /dev/null +++ b/docs/endpoints/security.md @@ -0,0 +1,61 @@ +--- +title: Security probes +description: Probe endpoints shaped to LOOK like dangerous gateway targets so the gateway can pattern-match them and refuse to forward. +--- + +# Security probes + +Five endpoints designed to look like things a gateway should refuse to +forward. The handlers themselves are **inert** — they never fetch a +URL, never escalate privileges, never emit smuggling-shaped responses. +Their value is the *shape* they present so a gateway URL-filter, +path-filter, or response-header limiter can pattern-match and refuse. + +A correctly-configured gateway never forwards these requests; the +api-test server only sees them if the gateway is mis-configured or +missing a rule. + +| Method | Path | Probe shape | Gateway should... | +| --- | --- | --- | --- | +| `GET` | `/v1/security/admin/secret` | privileged-looking path | refuse on path-filter. | +| `GET` | `/v1/security/fetch?url=...` | SSRF-shape query parameter | refuse when `url` points at localhost / link-local / non-allowlist. | +| `GET` | `/v1/security/big-headers` | ~32 KiB of response headers | reject or rewrite per RFC 7230 §3.2.5. | +| `POST` | `/v1/security/redirect-to?url=` | open-redirect shape (status 200 + custom `X-Would-Redirect-To` header) | refuse when `url` is unrestricted. | +| `GET` | `/v1/security/control-chars?q=` | control bytes in query | sanitize, strip, or pass through observably. | + +## Why the redirect probe returns 200 with a custom header + +Returning a 3xx with a caller-controlled `Location` is a literal open +redirect, even on a test fixture. We avoid both: status is 200 (no +auto-follow) and the URL lands in `X-Would-Redirect-To`, not +`Location`. Gateway URL-filters that scan response headers can still +pattern-match the `X-Would-Redirect-To` shape; gateways that only +inspect `Location` will see nothing, which is itself a useful finding +about that gateway's coverage. + +CodeQL's `go/unvalidated-url-redirection` rule does not trace +non-Location headers, so this design also keeps the static analyzer +clean without a per-file suppression. + +## What "WouldHaveFetched" tells you + +`/v1/security/fetch` always returns `{"asked_for": "...", "would_have_fetched": false}`. +The field is a contract: a gateway running this probe should observe +that the upstream **does not** fetch, and that any "the upstream made +a callback" signal in your monitoring is therefore a gateway bug +(unexpected egress). + +## Example + +```bash +# Privileged-path probe — gateway path-filter should refuse. +curl -is http://localhost:8080/v1/security/admin/secret | head -1 + +# SSRF-shape probe — gateway SSRF heuristics should refuse this URL. +curl -s 'http://localhost:8080/v1/security/fetch?url=http://169.254.169.254/latest/meta-data/' +# {"asked_for":"http://169.254.169.254/latest/meta-data/","would_have_fetched":false} + +# Big-headers probe — gateway header-size limit should reject. +curl -is http://localhost:8080/v1/security/big-headers | grep -c '^X-Big-Probe-' +# 64 +``` diff --git a/docs/endpoints/streaming.md b/docs/endpoints/streaming.md new file mode 100644 index 0000000..0c87519 --- /dev/null +++ b/docs/endpoints/streaming.md @@ -0,0 +1,69 @@ +--- +title: Streaming +description: Chunked, Server-Sent Events, and NDJSON endpoints for verifying gateway behavior on streaming responses. +--- + +# Streaming + +Three endpoints, one per common streaming wire format. Each one's body +is deterministic from `(count, seed)` so a gateway client can replay a +stream and bit-compare what came back. + +| Method | Path | Content-Type | Purpose | +| --- | --- | --- | --- | +| `GET` | `/v1/streaming/chunked` | `text/plain; charset=utf-8` | Transfer-Encoding: chunked, N text lines, one chunk per line. | +| `GET` | `/v1/streaming/sse` | `text/event-stream` | N SSE events with `id:` and `data:` fields; data is JSON. | +| `GET` | `/v1/streaming/ndjson` | `application/x-ndjson` | N newline-delimited JSON objects. | + +All three share the same query parameters. + +## Query parameters + +| Parameter | Default | Bounds | Description | +| --- | --- | --- | --- | +| `count` | `5` | `0` to `1000` | Number of items to emit. `0` produces an empty body but still returns `200`. | +| `delay_ms` | `0` | `0` to `5000` | Server-side delay between items, for exercising client read timeouts. Does not affect content. | +| `seed` | `""` | any string | Seed for the deterministic word picker. Same `(count, seed)` reproduces the same body. | + +Invalid values (non-integer, negative, over the bound) return `400`. + +## Determinism + +Each emitted item carries an index (`0`-based) and a word from a fixed +26-word dictionary. The word picked at index `i` is a stable function of +`(seed, i)` — re-seeded per item, so requesting index 7 of a 100-item +stream returns the same word it would in a 10-item stream. + +Two requests with the same `(count, seed)` produce bit-identical bodies. +Different seeds produce different bodies. + +## Examples + +```bash +# Chunked: 3 lines. +curl -s 'http://localhost:8080/v1/streaming/chunked?count=3&seed=fixed' +# chunk 0: uniform +# chunk 1: whiskey +# chunk 2: kilo + +# SSE: 2 events. +curl -s 'http://localhost:8080/v1/streaming/sse?count=2&seed=fixed' +# id: 0 +# data: {"id":0,"word":"uniform"} +# +# id: 1 +# data: {"id":1,"word":"whiskey"} + +# NDJSON: 2 lines, with 200ms delay between them. +curl -Ns 'http://localhost:8080/v1/streaming/ndjson?count=2&seed=fixed&delay_ms=200' +# {"index":0,"word":"uniform"} +# {"index":1,"word":"whiskey"} +``` + +## Why this exists + +A gateway proxy can quietly break streaming responses in three ways: by +buffering the entire body before flushing, by stripping `Transfer-Encoding: +chunked`, or by rewriting `text/event-stream` so the EventSource API +client gives up. These endpoints let you assert end-to-end that the proxy +preserves the wire format, the flush boundaries, and the content type. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 4bdf32a..18857e8 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -79,7 +79,7 @@ doesn't need curl/wget. api-test wants Postgres for the audit log. The [quickstart](quickstart.md) runs the binary in anonymous mode (no -Postgres needed) today; the full Postgres + Keycloak + portal stack -lands with M3. To deploy on your own infrastructure once auditing is -on, point `database.url` at a Postgres 14+ instance; migrations run -on boot. +Postgres needed); the full Postgres + Keycloak + portal stack is +available via `make dev`. To deploy on your own infrastructure with +auditing on, point `database.url` at a Postgres 14+ instance; +migrations run on boot. diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index fcce13a..ee81750 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -39,10 +39,10 @@ behind that connection — the thing the gateway actually talks to. - **Audit log**: every inbound request lands in Postgres with sanitized headers and bodies, the resolved identity, the response status and size, and the duration. -- **Portal** (M3): React 19 SPA embedded in the binary; Dashboard, +- **Portal**: React 19 SPA embedded in the binary; Dashboard, Endpoints with Try-It, Audit, API Keys, Config, Discovery (Redoc/Swagger UI over `/openapi.json`). -- **OpenAPI document** (M4) at `/openapi.{json,yaml}`, generated in-tree +- **OpenAPI document** at `/openapi.{json,yaml}`, generated in-tree from the registered endpoint metadata so it can't drift from the served routes. - **Operational ergonomics**: `--healthcheck` self-probe, graceful @@ -82,6 +82,6 @@ use mcp-test to validate MCP-gateway behavior. container image. - [Quickstart](quickstart.md) — `make dev` runs the binary in anonymous mode today; the full Postgres + Keycloak + portal stack - lands with M3. + lands with. - [Register with Plexara](register-with-plexara.md) — wire api-test in as a connection. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 24c79ef..7519f3a 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -13,14 +13,14 @@ make dev Today, `make dev` is aliased to `make dev-anon` and runs the binary against `configs/api-test.dev.yaml` (anonymous, no Postgres, no -Keycloak). That's the M1+M2 happy path; everything below the +Keycloak). That's the happy path; everything below the `/healthz` row in the table works. ```text make dev → go run ./cmd/api-test --config configs/api-test.dev.yaml ``` -The full Postgres + Keycloak + portal stack lands with M3. Once shipped, +The full Postgres + Keycloak + portal stack lands with. Once shipped, `make dev` will spin up the compose stack (`docker-compose.dev.yml`), poll containers, build the SPA into `internal/ui/dist`, and run the binary against @@ -44,12 +44,12 @@ When it's up (today, anonymous mode):
http://localhost:8080/portal/
-
Portal (M3+). Sign in with `dev` / `dev` (OIDC) or paste an API key.
+
Portal. Sign in with `dev` / `dev` (OIDC) or paste an API key.
http://localhost:8081/
-
Keycloak admin console (M3+, `admin` / `admin`).
+
Keycloak admin console (`admin` / `admin`).
diff --git a/docs/index.md b/docs/index.md index c62dcbb..d3c9eae 100644 --- a/docs/index.md +++ b/docs/index.md @@ -99,8 +99,7 @@ about gateway behavior without running fragile real-data fixtures. ## Where to next - New here? [Quickstart](getting-started/quickstart.md) gets you the - binary running in under a minute (anonymous mode today; the full - Postgres + Keycloak + portal stack lands with M3). + binary running in under a minute. - Configuring a deployment? [YAML reference](configuration/reference.md) documents every key with its default and environment override. - Wiring api-test into Plexara? [Register with Plexara](getting-started/register-with-plexara.md) diff --git a/docs/llms.txt b/docs/llms.txt index fa544e9..b8d44dc 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -8,7 +8,7 @@ The server itself is small and predictable on purpose; the value is the surface - [Overview](https://api-test.plexara.io/getting-started/overview/): What api-test is, who should use it, and why a separate test fixture matters. - [Installation](https://api-test.plexara.io/getting-started/installation/): Binary download, container image (GHCR), `go install`, building from source. -- [Quickstart](https://api-test.plexara.io/getting-started/quickstart/): `make dev` runs the binary in anonymous mode today; the full Postgres + Keycloak + portal stack lands with M3. +- [Quickstart](https://api-test.plexara.io/getting-started/quickstart/): `make dev` runs the binary in anonymous mode; the full Postgres + Keycloak + portal stack is also available via `make dev`. - [Register with Plexara](https://api-test.plexara.io/getting-started/register-with-plexara/): Wiring api-test in as a connection in a running Plexara API gateway, with one example per supported auth mode. ## Configuration diff --git a/docs/operations/audit.md b/docs/operations/audit.md index 80c4b04..8586fb9 100644 --- a/docs/operations/audit.md +++ b/docs/operations/audit.md @@ -73,7 +73,7 @@ The detail row. Same shape as `Event.Payload` in Go. | `response_body` | BYTEA | Same cap rules as request side. | | `response_size_bytes` | INTEGER | Captured prefix size. | | `response_truncated` | BOOLEAN | True when the outbound body exceeded the cap. | -| `replayed_from` | TEXT | When this event was a replay of another, points back. M3+. | +| `replayed_from` | TEXT | When this event was a replay of another, points back. | | `captured_at` | TIMESTAMPTZ | Insert time of the payload row. | Indexes: `(replayed_from)` partial, GIN on `request_headers` and @@ -142,7 +142,7 @@ offset. The Postgres store builds a parameterized SQL `SELECT ... FROM audit_events WHERE … ORDER BY ts DESC, id ASC LIMIT … OFFSET …` from the filter. -The portal API (M3+) wraps these filters in HTTP query params and +The portal API wraps these filters in HTTP query params and returns paginated JSON. Direct SQL is also fine; the schema is documented above and Postgres is the source of truth. diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 19197e1..85e077e 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -120,7 +120,7 @@ Structured JSON via slog, written to stderr. Override the level via ## Metrics -Prometheus metrics endpoint lands in M5. Until then, derive metrics +Prometheus metrics endpoint lands in. Until then, derive metrics from the structured access log or query the audit table: ```sql diff --git a/docs/operations/gateway-testing.md b/docs/operations/gateway-testing.md index 84e7bbe..7cde449 100644 --- a/docs/operations/gateway-testing.md +++ b/docs/operations/gateway-testing.md @@ -147,7 +147,7 @@ Then check Plexara's own audit log for the same call. The credential should be `[redacted]` there too. If it's plaintext on either side, the redaction policy isn't covering that key. -## Pagination detection (M4+) +## Pagination detection **Question**: did the gateway recognize the upstream's pagination cursor? diff --git a/docs/overrides/home.html b/docs/overrides/home.html index e17fe6d..87dcbef 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -84,7 +84,7 @@

Inspect every request from the browser

["audit", "Audit log", "Every request, filterable by method, path, and success / error. Auto-refreshing on a 5-second interval; click a row to inspect the full request and response."], ["audit-detail", "Inspection panel", "Side-pane detail card with timestamp, duration, request id, identity, remote address, byte counts, and the full headers / query / body trees for both sides of the call."], ["endpoints", "Endpoints", "Catalog of every registered route, grouped by behavior — identity, deterministic data, echo, controlled failure modes."], - ["endpoints-detail", "Endpoint detail", "Method, path, group, auth requirement, and an inline curl hint per route. Try-It panel arrives with the OpenAPI generator in M4."], + ["endpoints-detail", "Endpoint detail", "Method, path, group, auth requirement, and an inline curl hint per route. Try-It panel arrives with the OpenAPI generator in."], ["keys", "API keys", "Create or revoke Postgres-backed bcrypt keys. Plaintext is shown once, then never again."], ["config", "Config", "Read-only view of the running server config, with secrets masked. Useful for sanity-checking what's actually loaded."], ["about", "About", "Build info plus the same well-known metadata an MCP / API client sees: api endpoint, OIDC issuer, audience."] diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index ddee835..ec91d23 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -32,8 +32,8 @@ flowchart TB Echo_g["echo"] end Health["healthz / readyz"] - Portal["/portal/ (M3+)"] - OpenAPI["/openapi.json (M4+)"] + Portal["/portal/"] + OpenAPI["/openapi.json"] SPA["embedded SPA
(go:embed all:dist)"] end @@ -103,7 +103,7 @@ flowchart LR APIKey -->|match| Identity["Identity attached"] APIKey -->|no credential| Bearer["BearerAuthenticator"] Bearer -->|match| Identity - Bearer -->|no credential| OIDC["OIDCAuthenticator (M3+)"] + Bearer -->|no credential| OIDC["OIDCAuthenticator"] OIDC -->|match| Identity OIDC -->|no credential & allow_anonymous| Anon["Anonymous Identity"] OIDC -->|no credential & not anonymous| Reject["401"] diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 5eaffde..18be160 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -20,7 +20,7 @@ GET /readyz → 200 "ready" (normally) Both return plain text. Useful for K8s probes, load-balancer health checks, and the binary's own `--healthcheck` flag. -## OpenAPI document (M4+) +## OpenAPI document ```http GET /openapi.json @@ -32,7 +32,7 @@ Generated in-tree from the same metadata the portal uses, so it can't drift from the served routes. A boot-time self-check fails startup if a route is mounted without a doc entry (or vice versa). -## Discovery (M3+) +## Discovery ```http GET /docs @@ -41,7 +41,7 @@ GET /docs Renders a Redoc / Swagger UI view of `/openapi.json` for human inspection. The portal's Discovery page iframes this. -## Well-known metadata (M3+) +## Well-known metadata ```http GET /.well-known/oauth-protected-resource @@ -52,7 +52,7 @@ RFC 9728 protected-resource metadata advertises which OIDC issuer api-test accepts tokens from, so OAuth2-aware clients can discover the IdP without out-of-band config. -## Portal API (M3+) +## Portal API Read-only endpoints under `/api/v1/portal/`. All require an authenticated operator session (browser cookie or `X-API-Key`). @@ -76,7 +76,7 @@ The audit query filters mirror Go's `audit.QueryFilter` — `from`, `to`, `method`, `path`, `route_name`, `user_subject`, `session_id`, `status`, `success` — and every list endpoint paginates uniformly. -## Admin API (M3+) +## Admin API Mutation endpoints under `/api/v1/admin/`. Same auth requirements as the portal API; intended for portal use plus operator scripts. @@ -87,7 +87,7 @@ the portal API; intended for portal use plus operator scripts. | `POST /api/v1/admin/api-keys` | Mint a new key; response carries the plaintext **once**. | | `DELETE /api/v1/admin/api-keys/{name}` | Revoke a key. | -## Browser auth (M3+, when `portal.enabled` and `oidc.enabled`) +## Browser auth (when `portal.enabled` and `oidc.enabled`) | Method + path | Effect | | --- | --- | @@ -95,7 +95,7 @@ the portal API; intended for portal use plus operator scripts. | `GET /portal/auth/callback` | OIDC callback; sets the session cookie; 302 to `/portal/`. | | `POST /portal/auth/logout` | Clear the cookie; 302 to `/portal/`. | -## SPA (M3+) +## SPA ```http GET /portal/ → SPA index.html diff --git a/internal/server/server.go b/internal/server/server.go index bdc086e..b78d00a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,10 +29,16 @@ import ( "github.com/plexara/api-test/pkg/endpoints" "github.com/plexara/api-test/pkg/endpoints/data" "github.com/plexara/api-test/pkg/endpoints/echo" + "github.com/plexara/api-test/pkg/endpoints/export" "github.com/plexara/api-test/pkg/endpoints/failure" "github.com/plexara/api-test/pkg/endpoints/identity" + "github.com/plexara/api-test/pkg/endpoints/methods" + "github.com/plexara/api-test/pkg/endpoints/pagination" + "github.com/plexara/api-test/pkg/endpoints/security" + "github.com/plexara/api-test/pkg/endpoints/streaming" "github.com/plexara/api-test/pkg/httpmw" "github.com/plexara/api-test/pkg/httpsrv" + "github.com/plexara/api-test/pkg/oapi" ) // Application is the wired-up server, ready to be started with Run. @@ -118,7 +124,14 @@ func Build(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Appli return nil, fmt.Errorf("portal: %w", err) } - core := httpsrv.BuildMux(app.registry, app.readiness, endpointMW, portalDeps) + oapiDoc := buildOpenAPI(cfg, app.registry) + if err := oapi.SelfCheck(oapiDoc, app.registry); err != nil { + return nil, fmt.Errorf("openapi self-check: %w", err) + } + core, err := httpsrv.BuildMux(app.registry, app.readiness, endpointMW, portalDeps, &oapiDoc) + if err != nil { + return nil, fmt.Errorf("build mux: %w", err) + } // AccessLog + RequestID wrap the entire mux so health probes also get // request ids; identity/audit only run on endpoint group routes (via // endpointMW above). @@ -126,6 +139,23 @@ func Build(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Appli return app, nil } +// buildOpenAPI assembles the served OpenAPI document from the loaded config +// and the registered endpoint groups. The result is rendered once at boot +// inside BuildMux. +func buildOpenAPI(cfg *config.Config, registry *endpoints.Registry) oapi.Document { + opts := oapi.BuildOptions{ + Info: oapi.Info{ + Title: cfg.Server.Name, + Version: build.Version, + Description: cfg.Server.Description, + }, + Servers: []oapi.Server{{URL: cfg.Server.BaseURL}}, + APIKeyHeader: cfg.APIKeys.HeaderName, + BearerEnabled: len(cfg.Bearer.Tokens) > 0 || cfg.OIDC.Enabled, + } + return oapi.Build(registry, opts) +} + // buildPortal returns the portal handler bundle when cfg.Portal.Enabled is // true. Returns (nil, nil) when the portal is disabled — the mux falls back // to the bare /v1/* + /healthz surface. @@ -206,7 +236,14 @@ func BuildWithDeps(cfg *config.Config, logger *slog.Logger, chain *inbound.Chain return identityMW(auditMW(next)) } readiness := httpsrv.NewReadiness() - core := httpsrv.BuildMux(registry, readiness, endpointMW, nil) + oapiDoc := buildOpenAPI(cfg, registry) + core, err := httpsrv.BuildMux(registry, readiness, endpointMW, nil, &oapiDoc) + if err != nil { + // BuildWithDeps is a test/dev convenience; surface the error via + // panic so tests fail loudly rather than silently dropping the + // OpenAPI surface. + panic(fmt.Sprintf("BuildWithDeps: build mux: %v", err)) + } mux := httpmw.RequestID(httpmw.AccessLog(logger)(core)) return &Application{ cfg: cfg, @@ -234,6 +271,21 @@ func buildRegistry(cfg *config.Config) *endpoints.Registry { if cfg.Endpoints.Echo.Enabled { r.Add(echo.New(cfg.Audit.RedactKeys)) } + if cfg.Endpoints.Streaming.Enabled { + r.Add(streaming.New()) + } + if cfg.Endpoints.Pagination.Enabled { + r.Add(pagination.New()) + } + if cfg.Endpoints.Methods.Enabled { + r.Add(methods.New()) + } + if cfg.Endpoints.Security.Enabled { + r.Add(security.New()) + } + if cfg.Endpoints.Export.Enabled { + r.Add(export.New()) + } return r } diff --git a/mkdocs.yml b/mkdocs.yml index c05193d..4921f1a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,6 +76,11 @@ nav: - Data: endpoints/data.md - Failure Modes: endpoints/failure.md - Echo: endpoints/echo.md + - Streaming: endpoints/streaming.md + - Pagination: endpoints/pagination.md + - Methods: endpoints/methods.md + - Security: endpoints/security.md + - Export: endpoints/export.md - Operations: - Audit Log: operations/audit.md - Portal: operations/portal.md diff --git a/pkg/audit/event.go b/pkg/audit/event.go index 0cf6193..99bbb1e 100644 --- a/pkg/audit/event.go +++ b/pkg/audit/event.go @@ -14,6 +14,12 @@ import ( "time" ) +// ReplayHeaderName is the header attached to replayed requests so the +// audit middleware can populate Payload.ReplayedFrom on the new event +// row. Lives in this package so both the portal handler that sets it +// and the middleware that reads it share one source of truth. +const ReplayHeaderName = "X-Plexara-Replay-From" + // Event is the indexable summary written to audit_events. type Event struct { ID string `json:"id"` diff --git a/pkg/config/fixtures_test.go b/pkg/config/fixtures_test.go new file mode 100644 index 0000000..b5796a9 --- /dev/null +++ b/pkg/config/fixtures_test.go @@ -0,0 +1,87 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestShippedFixtureConfigsLoad guards every YAML under configs/ that +// ships with the binary. A merge that introduces a duplicate map key +// (the most common merge-resolution hazard for the per-group toggle +// section) causes yaml.v3's strict decoder to reject the file at +// Load time. Without this test no other target in `make verify` loads +// the fixtures, so the breakage slips through to first boot. +// +// Loads with APITEST_INSECURE set so the example config's +// skip_signature_verification field doesn't trip the secret-mode gate. +// The same env vars referenced by ${VAR:-default} interpolation get +// populated with safe stand-ins. +func TestShippedFixtureConfigsLoad(t *testing.T) { + repoRoot := findRepoRoot(t) + configsDir := filepath.Join(repoRoot, "configs") + + entries, err := os.ReadDir(configsDir) + if err != nil { + t.Fatalf("read configs dir: %v", err) + } + + // Provide values for the ${VAR:-default} placeholders that the + // fixtures reference. Values are dummy but pass the load-time + // validators (which only check shape, not content). + // Future-proofing: if a fixture flips skip_signature_verification + // to true, Load gates on this env var. Setting it here avoids a + // silent re-failure after such a change. + t.Setenv("APITEST_INSECURE", "1") + t.Setenv("APITEST_COOKIE_SECRET", "test-cookie-secret-32-bytes-long!!") + t.Setenv("APITEST_DEV_KEY", "test-dev-key") + t.Setenv("APITEST_DEV_BEARER", "test-dev-bearer") + t.Setenv("APITEST_OIDC_ISSUER", "http://localhost:8081/realms/api-test") + t.Setenv("APITEST_OIDC_AUDIENCE", "api-test") + t.Setenv("PLEXARA_ADMIN_URL", "http://localhost:9000/api/v1/admin/api-gateway/connections") + t.Setenv("PLEXARA_ADMIN_AUTH", "") + + loaded := 0 + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasPrefix(name, "api-test.") || !strings.HasSuffix(name, ".yaml") { + continue + } + path := filepath.Join(configsDir, name) + t.Run(name, func(t *testing.T) { + if _, err := Load(path); err != nil { + t.Fatalf("config.Load(%s) failed: %v", path, err) + } + }) + loaded++ + } + if loaded == 0 { + t.Fatalf("no api-test.*.yaml fixtures found under %s", configsDir) + } +} + +// findRepoRoot walks up from the current test working directory until +// it finds a directory containing a go.mod whose module path matches +// the project. Simpler than passing in a path; the test binary's CWD +// is the package directory. +func findRepoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("could not find go.mod walking up from %s", dir) + } + dir = parent + } +} diff --git a/pkg/endpoints/export/export.go b/pkg/endpoints/export/export.go new file mode 100644 index 0000000..8fe5729 --- /dev/null +++ b/pkg/endpoints/export/export.go @@ -0,0 +1,279 @@ +// Package export provides large/long-running endpoints intended as +// targets for the Plexara API gateway's api_export tool. The point is +// to verify the gateway handles big bodies and slow first-byte +// scenarios without timing out, truncating, or dropping connections. +// +// Endpoints: +// +// - GET /v1/export/big-body?size_kb=N&seed=S — N KiB JSON-array +// - GET /v1/export/csv?rows=N&seed=S — N-row CSV +// - GET /v1/export/long-running?duration_ms=N — slow first-byte +package export + +import ( + "context" + "crypto/sha256" + "encoding/csv" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const ( + groupName = "export" + + // maxBodyKB bounds /v1/export/big-body responses at 10 MiB so a + // runaway test request can't consume unbounded memory or wall + // time. Real api_export usage moves more than this; the cap is + // fixture-side defense, not a contract limit. + maxBodyKB = 10240 // 10 MiB + + // maxRows bounds /v1/export/csv at 250k rows (~ a few MiB). + maxRows = 250_000 + + // maxDurationMS bounds /v1/export/long-running at 60 s. Long + // enough to exercise gateway request-timeout policy, short + // enough that test runs stay tractable. + maxDurationMS = 60_000 + + // defaultSizeKB and defaultRows are the values returned when the + // caller omits the size knob. + defaultSizeKB = 64 + defaultRows = 1000 +) + +// Group implements endpoints.Endpoints for the export group. +type Group struct{} + +// New returns a Group. +func New() *Group { return &Group{} } + +// Name implements endpoints.Endpoints. +func (Group) Name() string { return groupName } + +// Routes implements endpoints.Endpoints. +func (Group) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{ + { + Name: "export_big_body", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/export/big-body", + Description: "Stream an approximately N KiB JSON array of deterministic rows. Tests how the gateway forwards large response bodies (buffering, content-length, connection reuse).", + QueryParams: (*BigBodyQuery)(nil), + ResponseBody: (*BigBodyRow)(nil), + }, + { + Name: "export_csv", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/export/csv", + Description: "Return a deterministic CSV with N rows. Tests gateway behavior on non-JSON content types and large text bodies.", + QueryParams: (*CSVQuery)(nil), + ResponseBody: (*CSVRow)(nil), + }, + { + Name: "export_long_running", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/export/long-running", + Description: "Sleep for duration_ms before responding with timing info. Tests how the gateway handles slow first-byte. Stops early on client disconnect (no goroutine leak).", + QueryParams: (*LongRunningQuery)(nil), + ResponseBody: (*LongRunningResponse)(nil), + }, + } +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/export/big-body", mw(http.HandlerFunc(g.bigBody))) + mux.Handle("GET /v1/export/csv", mw(http.HandlerFunc(g.csv))) + mux.Handle("GET /v1/export/long-running", mw(http.HandlerFunc(g.longRunning))) +} + +// BigBodyQuery documents the big-body query parameters. +type BigBodyQuery struct { + SizeKB int `json:"size_kb,omitempty"` + Seed string `json:"seed,omitempty"` +} + +// BigBodyRow is one element of the big-body response array. +type BigBodyRow struct { + Index int `json:"index"` + Value string `json:"value"` +} + +// CSVQuery documents the csv query parameters. +type CSVQuery struct { + Rows int `json:"rows,omitempty"` + Seed string `json:"seed,omitempty"` +} + +// CSVRow documents the csv row shape. Wire format is text/csv; this +// struct exists so the OpenAPI reflector can produce a schema for the +// row contents that match the produced columns. +type CSVRow struct { + Index int `json:"index"` + Value string `json:"value"` +} + +// LongRunningQuery documents the long-running query parameters. +type LongRunningQuery struct { + DurationMS int `json:"duration_ms,omitempty"` +} + +// LongRunningResponse is the body of /v1/export/long-running. +type LongRunningResponse struct { + SleptMS int `json:"slept_ms"` +} + +// bigBody streams a large JSON array of {index, value} rows where value +// is hex(sha256(seed:index))[:16]. The handler stops writing on client +// disconnect. +func (g *Group) bigBody(w http.ResponseWriter, r *http.Request) { + sizeKB, err := boundedDefault(r.URL.Query().Get("size_kb"), + defaultSizeKB, maxBodyKB, "size_kb") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + seed := r.URL.Query().Get("seed") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + target := sizeKB * 1024 + // Each row is roughly 50 bytes ({"index":N,"value":"..."}). Cap + // the loop independently in case our size estimate drifts so we + // never emit dramatically more than requested. + maxIter := target/40 + 64 + written := 1 + _, _ = w.Write([]byte("[")) + first := true + for i := 0; i < maxIter && written < target; i++ { + if r.Context().Err() != nil { + return + } + row := BigBodyRow{Index: i, Value: deterministicValue(seed, i)} + enc, _ := json.Marshal(row) + if !first { + _, _ = w.Write([]byte(",")) + written++ + } + n, _ := w.Write(enc) + written += n + first = false + if flusher != nil && i%64 == 0 { + flusher.Flush() + } + } + _, _ = w.Write([]byte("]")) +} + +// csv writes a CSV with header + N rows. Content-Type is text/csv so a +// gateway has to handle non-JSON bodies correctly. +func (g *Group) csv(w http.ResponseWriter, r *http.Request) { + rows, err := boundedDefault(r.URL.Query().Get("rows"), + defaultRows, maxRows, "rows") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + seed := r.URL.Query().Get("seed") + + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + // encoding/csv handles RFC 4180 escaping (commas, quotes, newlines) + // regardless of what seed contains. Using it instead of fmt.Fprintf + // also keeps gosec's G705 (XSS-via-taint) check from flagging seed + // flowing into a templated writer. + cw := csv.NewWriter(w) + _ = cw.Write([]string{"index", "value"}) + for i := 0; i < rows; i++ { + if r.Context().Err() != nil { + cw.Flush() + return + } + _ = cw.Write([]string{strconv.Itoa(i), deterministicValue(seed, i)}) + if flusher != nil && i%256 == 0 { + cw.Flush() + flusher.Flush() + } + } + cw.Flush() +} + +// longRunning sleeps for the requested duration before responding, +// honoring r.Context() cancellation so a client disconnect aborts the +// goroutine instead of running out the full duration. +func (g *Group) longRunning(w http.ResponseWriter, r *http.Request) { + dur, err := boundedDefault(r.URL.Query().Get("duration_ms"), + 1000, maxDurationMS, "duration_ms") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + if !waitOrCancel(r.Context(), time.Duration(dur)*time.Millisecond) { + // Client gave up before we got here; don't try to write. + return + } + writeJSONOK(w, LongRunningResponse{SleptMS: dur}) +} + +// waitOrCancel sleeps for d or returns early when ctx is cancelled. +// Reports true when the wait completed normally, false on cancel. +func waitOrCancel(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + +// deterministicValue returns hex(sha256(seed:index))[:16] as a stable +// per-(seed, index) value. Matches the pagination group's +// deterministicValue style so test fixtures share semantics across +// groups. +func deterministicValue(seed string, index int) string { + combined := seed + ":" + strconv.Itoa(index) + sum := sha256.Sum256([]byte(combined)) + return hex.EncodeToString(sum[:8]) +} + +func boundedDefault(raw string, def, upper int, name string) (int, error) { + if raw == "" { + return def, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + if n > upper { + return 0, fmt.Errorf("%s %d exceeds max %d", name, n, upper) + } + return n, nil +} + +func writeJSONOK(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(body) +} + +func writeBadRequest(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": strings.TrimSpace(msg)}) +} diff --git a/pkg/endpoints/export/export_test.go b/pkg/endpoints/export/export_test.go new file mode 100644 index 0000000..7170a25 --- /dev/null +++ b/pkg/endpoints/export/export_test.go @@ -0,0 +1,217 @@ +package export + +import ( + "bufio" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newMux(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + New().Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestBigBody_RespectsSize(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/export/big-body?size_kb=8&seed=x", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + body := w.Body.Bytes() + if !strings.HasPrefix(string(body), "[") || !strings.HasSuffix(string(body), "]") { + t.Errorf("body should be a JSON array; head=%q tail=%q", + body[:10], body[len(body)-10:]) + } + // Should be in the ballpark of 8 KiB. Allow generous slack; the + // loop overshoots by one row at most. + got := len(body) + if got < 6*1024 || got > 12*1024 { + t.Errorf("body length %d not in [6 KiB, 12 KiB]", got) + } + // Body should parse as JSON. + var arr []BigBodyRow + if err := json.Unmarshal(body, &arr); err != nil { + t.Fatalf("body not valid JSON: %v", err) + } + if len(arr) == 0 { + t.Errorf("array empty") + } +} + +func TestBigBody_Deterministic(t *testing.T) { + mux := newMux(t) + a := getBody(t, mux, "/v1/export/big-body?size_kb=4&seed=fixed") + b := getBody(t, mux, "/v1/export/big-body?size_kb=4&seed=fixed") + if a != b { + t.Errorf("same (size, seed) produced different bodies") + } + c := getBody(t, mux, "/v1/export/big-body?size_kb=4&seed=other") + if a == c { + t.Errorf("different seed produced same body") + } +} + +func TestBigBody_CapEnforced(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/export/big-body?size_kb=99999", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("oversized request should be 400, got %d", w.Code) + } +} + +func TestCSV_HeaderAndRows(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/export/csv?rows=5&seed=x", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/csv") { + t.Errorf("Content-Type = %q", ct) + } + lines := []string{} + scanner := bufio.NewScanner(strings.NewReader(w.Body.String())) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + if len(lines) != 6 { + t.Fatalf("got %d lines, want 6 (header + 5 rows): %v", len(lines), lines) + } + if lines[0] != "index,value" { + t.Errorf("header = %q", lines[0]) + } + for i, line := range lines[1:] { + parts := strings.Split(line, ",") + if len(parts) != 2 { + t.Errorf("row %d malformed: %q", i, line) + continue + } + if parts[0] != itoa(i) { + t.Errorf("row %d index = %q, want %d", i, parts[0], i) + } + } +} + +func TestCSV_RowsBounded(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/export/csv?rows=999999999", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("oversized rows should be 400, got %d", w.Code) + } +} + +func TestLongRunning_HonorsDuration(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + start := time.Now() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/export/long-running?duration_ms=100", nil)) + elapsed := time.Since(start) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if elapsed < 90*time.Millisecond || elapsed > 500*time.Millisecond { + t.Errorf("elapsed %v outside [90ms, 500ms]", elapsed) + } + var resp LongRunningResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.SleptMS != 100 { + t.Errorf("SleptMS = %d, want 100", resp.SleptMS) + } +} + +func TestLongRunning_StopsOnContextCancel(t *testing.T) { + mux := newMux(t) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + req := httptest.NewRequest(http.MethodGet, "/v1/export/long-running?duration_ms=5000", nil).WithContext(ctx) + w := httptest.NewRecorder() + start := time.Now() + mux.ServeHTTP(w, req) + elapsed := time.Since(start) + if elapsed > 500*time.Millisecond { + t.Errorf("context cancel not honored: %v", elapsed) + } +} + +func TestLongRunning_DurationBounded(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/export/long-running?duration_ms=999999999", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("oversized duration should be 400, got %d", w.Code) + } +} + +func TestValidation(t *testing.T) { + mux := newMux(t) + cases := []string{ + "/v1/export/big-body?size_kb=abc", + "/v1/export/big-body?size_kb=0", + "/v1/export/big-body?size_kb=-1", + "/v1/export/csv?rows=0", + "/v1/export/csv?rows=abc", + "/v1/export/long-running?duration_ms=0", + } + for _, p := range cases { + t.Run(p, func(t *testing.T) { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, p, nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400", w.Code) + } + }) + } +} + +func TestRoutes_Shape(t *testing.T) { + routes := New().Routes() + if len(routes) != 3 { + t.Fatalf("got %d routes, want 3", len(routes)) + } + for _, r := range routes { + if r.Group != groupName { + t.Errorf("%s group = %q", r.Path, r.Group) + } + if r.QueryParams == nil { + t.Errorf("%s missing QueryParams", r.Path) + } + if r.ResponseBody == nil { + t.Errorf("%s missing ResponseBody", r.Path) + } + } +} + +func getBody(t *testing.T, mux *http.ServeMux, path string) string { + t.Helper() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d for %s", w.Code, path) + } + return w.Body.String() +} + +// itoa avoids strconv import in the test for one call site. +func itoa(n int) string { + if n == 0 { + return "0" + } + digits := "" + for n > 0 { + digits = string('0'+byte(n%10)) + digits + n /= 10 + } + return digits +} diff --git a/pkg/endpoints/methods/methods.go b/pkg/endpoints/methods/methods.go new file mode 100644 index 0000000..03e9547 --- /dev/null +++ b/pkg/endpoints/methods/methods.go @@ -0,0 +1,91 @@ +// Package methods provides a method-matrix endpoint that accepts every +// common HTTP verb at a single path and reports back the method it +// observed. Used to verify the gateway preserves HTTP verbs verbatim +// when forwarding (no GET→POST rewrites, no OPTIONS swallowed by a +// CORS pre-flight handler, etc.). +package methods + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const groupName = "methods" + +// supportedMethods is the matrix of verbs the endpoint accepts. OPTIONS +// is included so callers can sanity-check that the gateway's CORS layer +// (if any) doesn't strip pre-flight responses on the way back. +var supportedMethods = []string{ + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + http.MethodHead, + http.MethodOptions, +} + +// Group implements endpoints.Endpoints for the methods group. +type Group struct{} + +// New returns a Group. +func New() *Group { return &Group{} } + +// Name implements endpoints.Endpoints. +func (Group) Name() string { return groupName } + +// Routes implements endpoints.Endpoints. One EndpointMeta entry per +// supported method so the OpenAPI generator and portal list each verb +// explicitly. The handler is shared across all of them. +func (Group) Routes() []endpoints.EndpointMeta { + out := make([]endpoints.EndpointMeta, 0, len(supportedMethods)) + for _, m := range supportedMethods { + out = append(out, endpoints.EndpointMeta{ + Name: "method_" + strings.ToLower(m), + Group: groupName, + Method: m, + Path: "/v1/method/echo", + Description: "Echo the HTTP method the server observed. Verify the gateway preserves the request verb across forwarding.", + ResponseBody: (*Response)(nil), + }) + } + return out +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + for _, m := range supportedMethods { + mux.Handle(m+" /v1/method/echo", mw(http.HandlerFunc(g.handle))) + } +} + +// Response is the wire shape of /v1/method/echo. HEAD bodies are +// suppressed at the HTTP layer (Go's ResponseWriter discards body on +// HEAD), so the schema is documented here for the other verbs. +type Response struct { + Method string `json:"method"` + Path string `json:"path"` + Query map[string][]string `json:"query,omitempty"` +} + +func (g *Group) handle(w http.ResponseWriter, r *http.Request) { + resp := Response{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + } + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodOptions { + // Advertise the verb matrix in Allow so an OPTIONS probe is + // informative even before the gateway forwards a real request. + w.Header().Set("Allow", strings.Join(supportedMethods, ", ")) + } + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _ = json.NewEncoder(w).Encode(resp) +} diff --git a/pkg/endpoints/methods/methods_test.go b/pkg/endpoints/methods/methods_test.go new file mode 100644 index 0000000..a759fcf --- /dev/null +++ b/pkg/endpoints/methods/methods_test.go @@ -0,0 +1,110 @@ +package methods + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newMux(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + New().Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestEveryMethodEchoes(t *testing.T) { + mux := newMux(t) + for _, m := range supportedMethods { + t.Run(m, func(t *testing.T) { + req := httptest.NewRequest(m, "/v1/method/echo", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if m == http.MethodHead { + if w.Body.Len() != 0 { + t.Errorf("HEAD should have empty body, got %q", w.Body.String()) + } + return + } + var resp Response + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body=%q)", err, w.Body.String()) + } + if resp.Method != m { + t.Errorf("body method = %q, want %q", resp.Method, m) + } + if resp.Path != "/v1/method/echo" { + t.Errorf("body path = %q", resp.Path) + } + }) + } +} + +func TestOptionsAdvertisesAllow(t *testing.T) { + mux := newMux(t) + req := httptest.NewRequest(http.MethodOptions, "/v1/method/echo", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + allow := w.Header().Get("Allow") + for _, m := range supportedMethods { + if !strings.Contains(allow, m) { + t.Errorf("Allow header missing %q (have %q)", m, allow) + } + } +} + +func TestQueryEchoed(t *testing.T) { + mux := newMux(t) + req := httptest.NewRequest(http.MethodGet, "/v1/method/echo?a=1&a=2&b=x", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var resp Response + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if got := resp.Query["a"]; len(got) != 2 || got[0] != "1" || got[1] != "2" { + t.Errorf("query a wrong: %v", got) + } + if resp.Query["b"][0] != "x" { + t.Errorf("query b wrong: %v", resp.Query["b"]) + } +} + +func TestUnsupportedMethodReturns405(t *testing.T) { + mux := newMux(t) + // CONNECT and TRACE are not in supportedMethods; Go's mux returns + // 405 Method Not Allowed when patterns for the path exist for + // other verbs. + req := httptest.NewRequest(http.MethodConnect, "/v1/method/echo", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("CONNECT status = %d, want 405", w.Code) + } +} + +func TestRoutes_OnePerMethod(t *testing.T) { + routes := New().Routes() + if len(routes) != len(supportedMethods) { + t.Fatalf("got %d routes, want %d (one per verb)", len(routes), len(supportedMethods)) + } + seen := map[string]bool{} + for _, r := range routes { + if r.Group != groupName { + t.Errorf("%s group = %q", r.Method, r.Group) + } + if r.Path != "/v1/method/echo" { + t.Errorf("%s path = %q", r.Method, r.Path) + } + if seen[r.Method] { + t.Errorf("duplicate route for %s", r.Method) + } + seen[r.Method] = true + } +} diff --git a/pkg/endpoints/pagination/pagination.go b/pkg/endpoints/pagination/pagination.go new file mode 100644 index 0000000..78d8dca --- /dev/null +++ b/pkg/endpoints/pagination/pagination.go @@ -0,0 +1,389 @@ +// Package pagination provides three deterministic paginated endpoints, +// one per cursor style the Plexara API gateway recognizes: +// +// - Link header (RFC 5988) — /v1/pagination/link +// - OData v4 ($top, $skip, @odata.nextLink) — /v1/pagination/odata +// - Opaque cursor — /v1/pagination/cursor +// +// Each one slices a deterministic synthetic dataset of (id, value) pairs. +// Same (total, page-shape) produces the same items, so a gateway client +// can assert that pagination metadata (Link headers, nextLink URLs, +// opaque cursors) survive the proxy hop intact. +package pagination + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const ( + groupName = "pagination" + + // defaultTotal is the synthetic dataset size when ?total= is + // omitted. 100 is large enough to need pagination but small + // enough that "fetch every page" tests stay snappy. + defaultTotal = 100 + + // defaultPageSize is the page size when ?per_page/$top/limit is + // omitted. + defaultPageSize = 10 + + // maxTotal bounds the synthetic dataset. Tests that need more + // items belong on the export group. + maxTotal = 10000 + + // maxPageSize bounds a single page's item count. + maxPageSize = 1000 +) + +// Group implements endpoints.Endpoints for the pagination group. +type Group struct{} + +// New returns a Group. +func New() *Group { return &Group{} } + +// Name implements endpoints.Endpoints. +func (Group) Name() string { return groupName } + +// Routes implements endpoints.Endpoints. +func (Group) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{ + { + Name: "pagination_link", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/pagination/link", + Description: "Paginate via RFC 5988 Link header (rel=next/prev/first/last). Verify the gateway preserves Link header URLs through proxying.", + QueryParams: (*LinkQuery)(nil), + ResponseBody: (*LinkResponse)(nil), + }, + { + Name: "pagination_odata", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/pagination/odata", + Description: "Paginate via OData v4 $top / $skip with @odata.nextLink in the body. Verify the gateway preserves the nextLink URL.", + QueryParams: (*ODataQuery)(nil), + ResponseBody: (*ODataResponse)(nil), + }, + { + Name: "pagination_cursor", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/pagination/cursor", + Description: "Paginate via opaque base64 cursor. Verify the gateway round-trips the cursor value without interpretation.", + QueryParams: (*CursorQuery)(nil), + ResponseBody: (*CursorResponse)(nil), + }, + } +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/pagination/link", mw(http.HandlerFunc(g.link))) + mux.Handle("GET /v1/pagination/odata", mw(http.HandlerFunc(g.odata))) + mux.Handle("GET /v1/pagination/cursor", mw(http.HandlerFunc(g.cursor))) +} + +// Item is the shared item shape across all three styles. value is +// hex(sha256(id)[:8]) so a caller can replay any (id) and bit-compare. +type Item struct { + ID int `json:"id"` + Value string `json:"value"` +} + +// LinkQuery documents the link-style query parameters. +type LinkQuery struct { + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + Total int `json:"total,omitempty"` +} + +// LinkResponse is the body shape for /v1/pagination/link. +type LinkResponse struct { + Items []Item `json:"items"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` +} + +// ODataQuery documents the OData query parameters. The OData spec uses +// $top and $skip, which aren't valid Go identifiers; the json tags +// rename them for parameter docs. +type ODataQuery struct { + Top int `json:"$top,omitempty"` + Skip int `json:"$skip,omitempty"` + Total int `json:"total,omitempty"` +} + +// ODataResponse is the body shape for /v1/pagination/odata. +type ODataResponse struct { + Value []Item `json:"value"` + Count int `json:"@odata.count"` + NextLink string `json:"@odata.nextLink,omitempty"` +} + +// CursorQuery documents the cursor-style query parameters. +type CursorQuery struct { + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` + Total int `json:"total,omitempty"` +} + +// CursorResponse is the body shape for /v1/pagination/cursor. +type CursorResponse struct { + Items []Item `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// link serves the RFC 5988 Link-header style. +func (g *Group) link(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + page, err := positiveDefault(q.Get("page"), 1) + if err != nil { + writeBadRequest(w, "page must be a positive integer") + return + } + perPage, err := boundedDefault(q.Get("per_page"), defaultPageSize, maxPageSize, "per_page") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + total, err := boundedDefault(q.Get("total"), defaultTotal, maxTotal, "total") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + + // Validate page against lastPage BEFORE computing `start`. The + // untrusted `page` value combined with `perPage` would overflow + // int multiplication (e.g. page=MaxInt) and produce a negative + // `start` that bypasses the past-end check. lastPage is bounded + // by maxTotal, so this comparison is always safe. + lastPage := lastPageFor(total, perPage) + if page > lastPage { + writeBadRequest(w, fmt.Sprintf("page %d is past end of dataset (total=%d, per_page=%d, last_page=%d)", + page, total, perPage, lastPage)) + return + } + start := (page - 1) * perPage + items := sliceItems(start, perPage, total) + + base := requestBaseURL(r) + parts := []string{ + linkHeader(base, "first", 1, perPage, total), + linkHeader(base, "last", lastPage, perPage, total), + } + if page > 1 { + parts = append(parts, linkHeader(base, "prev", page-1, perPage, total)) + } + if page < lastPage { + parts = append(parts, linkHeader(base, "next", page+1, perPage, total)) + } + w.Header().Set("Link", strings.Join(parts, ", ")) + writeJSON(w, http.StatusOK, LinkResponse{ + Items: items, Page: page, PerPage: perPage, Total: total, + }) +} + +// odata serves the OData v4 $top / $skip / @odata.nextLink style. +func (g *Group) odata(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + top, err := boundedDefault(q.Get("$top"), defaultPageSize, maxPageSize, "$top") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + skip, err := nonNegativeDefault(q.Get("$skip"), 0) + if err != nil { + writeBadRequest(w, "$skip must be a non-negative integer") + return + } + total, err := boundedDefault(q.Get("total"), defaultTotal, maxTotal, "total") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + + if skip >= total { + writeBadRequest(w, fmt.Sprintf("$skip %d is past end of dataset (total=%d)", skip, total)) + return + } + + items := sliceItems(skip, top, total) + resp := ODataResponse{Value: items, Count: total} + if skip+top < total { + resp.NextLink = fmt.Sprintf("%s?$top=%d&$skip=%d&total=%d", + requestBaseURL(r), top, skip+top, total) + } + writeJSON(w, http.StatusOK, resp) +} + +// cursor serves the opaque-cursor style. The cursor is a base64-encoded +// integer offset; clients shouldn't interpret it, only pass it back. +func (g *Group) cursor(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + limit, err := boundedDefault(q.Get("limit"), defaultPageSize, maxPageSize, "limit") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + total, err := boundedDefault(q.Get("total"), defaultTotal, maxTotal, "total") + if err != nil { + writeBadRequest(w, err.Error()) + return + } + + offset := 0 + if c := q.Get("cursor"); c != "" { + n, derr := decodeCursor(c) + if derr != nil { + writeBadRequest(w, "cursor is malformed") + return + } + offset = n + } + if offset >= total { + writeBadRequest(w, fmt.Sprintf("cursor offset %d is past end of dataset (total=%d)", offset, total)) + return + } + + items := sliceItems(offset, limit, total) + resp := CursorResponse{Items: items} + if offset+limit < total { + resp.NextCursor = encodeCursor(offset + limit) + } + writeJSON(w, http.StatusOK, resp) +} + +// sliceItems returns the [start, start+pageSize) window of the synthetic +// dataset, clamped to total. Returns nil (not []Item{}) when start is +// already past total so the response body is consistent across styles. +func sliceItems(start, pageSize, total int) []Item { + if start >= total { + return nil + } + end := start + pageSize + if end > total { + end = total + } + out := make([]Item, 0, end-start) + for i := start; i < end; i++ { + out = append(out, Item{ID: i, Value: deterministicValue(i)}) + } + return out +} + +// deterministicValue returns hex(sha256(id)[:8]) as a stable per-id +// value. Same id always produces the same value, across builds and +// across pagination styles, so a gateway test can assert "items at +// offset N from /link match items at $skip=N from /odata". +func deterministicValue(id int) string { + sum := sha256.Sum256([]byte(strconv.Itoa(id))) + return hex.EncodeToString(sum[:8]) +} + +// lastPageFor returns the 1-indexed last page number. Returns 1 when +// total is 0 so the response body and Link header remain well-formed. +func lastPageFor(total, perPage int) int { + if total <= 0 { + return 1 + } + return (total + perPage - 1) / perPage +} + +// linkHeader builds one RFC 5988 Link header entry: ; rel="rel". +func linkHeader(base, rel string, page, perPage, total int) string { + return fmt.Sprintf(`<%s?page=%d&per_page=%d&total=%d>; rel=%q`, + base, page, perPage, total, rel) +} + +// requestBaseURL returns the scheme+host+path with no query string. Used +// by link and odata to construct next/prev URLs that point back at the +// same handler. +func requestBaseURL(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + return fmt.Sprintf("%s://%s%s", scheme, r.Host, r.URL.Path) +} + +// encodeCursor wraps an int offset as an opaque base64 string. URL-safe +// encoding so the value round-trips through a query parameter without +// further escaping. +func encodeCursor(offset int) string { + return base64.RawURLEncoding.EncodeToString([]byte(strconv.Itoa(offset))) +} + +// decodeCursor is the inverse of encodeCursor. +func decodeCursor(s string) (int, error) { + raw, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return 0, err + } + n, err := strconv.Atoi(string(raw)) + if err != nil { + return 0, err + } + if n < 0 { + return 0, fmt.Errorf("cursor offset negative") + } + return n, nil +} + +func positiveDefault(raw string, def int) (int, error) { + if raw == "" { + return def, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return 0, fmt.Errorf("must be a positive integer") + } + return n, nil +} + +func nonNegativeDefault(raw string, def int) (int, error) { + if raw == "" { + return def, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return 0, fmt.Errorf("must be a non-negative integer") + } + return n, nil +} + +func boundedDefault(raw string, def, upper int, name string) (int, error) { + if raw == "" { + return def, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + return 0, fmt.Errorf("%s must be a positive integer", name) + } + if n > upper { + return 0, fmt.Errorf("%s %d exceeds max %d", name, n, upper) + } + return n, nil +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func writeBadRequest(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/pkg/endpoints/pagination/pagination_test.go b/pkg/endpoints/pagination/pagination_test.go new file mode 100644 index 0000000..0b2bf34 --- /dev/null +++ b/pkg/endpoints/pagination/pagination_test.go @@ -0,0 +1,303 @@ +package pagination + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newMux(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + New().Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestLink_Defaults(t *testing.T) { + mux := newMux(t) + req := httptest.NewRequest(http.MethodGet, "/v1/pagination/link", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d (body=%s)", w.Code, w.Body.String()) + } + var body LinkResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Page != 1 || body.PerPage != defaultPageSize || body.Total != defaultTotal { + t.Errorf("defaults wrong: page=%d per_page=%d total=%d", body.Page, body.PerPage, body.Total) + } + if len(body.Items) != defaultPageSize { + t.Errorf("got %d items, want %d", len(body.Items), defaultPageSize) + } + if body.Items[0].ID != 0 || body.Items[len(body.Items)-1].ID != defaultPageSize-1 { + t.Errorf("page-1 item ids wrong: first=%d last=%d", body.Items[0].ID, body.Items[len(body.Items)-1].ID) + } +} + +func TestLink_HeaderShape(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/pagination/link?page=2&per_page=10&total=50", nil)) + + header := w.Header().Get("Link") + for _, rel := range []string{`rel="first"`, `rel="last"`, `rel="prev"`, `rel="next"`} { + if !strings.Contains(header, rel) { + t.Errorf("Link header missing %s\n%s", rel, header) + } + } + // First page has no prev; last page has no next. + wFirst := httptest.NewRecorder() + mux.ServeHTTP(wFirst, httptest.NewRequest(http.MethodGet, "/v1/pagination/link?page=1&per_page=10&total=50", nil)) + if strings.Contains(wFirst.Header().Get("Link"), `rel="prev"`) { + t.Errorf("page 1 should not have prev: %s", wFirst.Header().Get("Link")) + } + + wLast := httptest.NewRecorder() + mux.ServeHTTP(wLast, httptest.NewRequest(http.MethodGet, "/v1/pagination/link?page=5&per_page=10&total=50", nil)) + if strings.Contains(wLast.Header().Get("Link"), `rel="next"`) { + t.Errorf("last page should not have next: %s", wLast.Header().Get("Link")) + } +} + +func TestLink_PartialLastPage(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/pagination/link?page=3&per_page=10&total=25", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var body LinkResponse + _ = json.Unmarshal(w.Body.Bytes(), &body) + if len(body.Items) != 5 { + t.Errorf("last partial page should have 5 items, got %d", len(body.Items)) + } +} + +func TestLink_PastEnd(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/pagination/link?page=100&per_page=10&total=20", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("past-end should be 400, got %d", w.Code) + } +} + +// TestLink_HugePageNoOverflow guards against (page-1)*perPage integer +// overflow producing a negative start that bypasses the past-end check +// and returns items with negative IDs. Regression for an earlier +// implementation that validated `start >= total` after the multiplication. +func TestLink_HugePageNoOverflow(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/v1/pagination/link?page=9223372036854775807&per_page=10&total=100", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("huge page should be 400, got %d (body=%s)", w.Code, w.Body.String()) + } +} + +func TestOData_NextLink(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/pagination/odata?$top=10&$skip=0&total=25", nil)) + var resp ODataResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Count != 25 { + t.Errorf("@odata.count = %d, want 25", resp.Count) + } + if !strings.Contains(resp.NextLink, "$skip=10") { + t.Errorf("nextLink should advance to $skip=10: %q", resp.NextLink) + } + if len(resp.Value) != 10 { + t.Errorf("page size wrong: %d", len(resp.Value)) + } +} + +func TestOData_LastPageNoNextLink(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/pagination/odata?$top=10&$skip=20&total=25", nil)) + var resp ODataResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.NextLink != "" { + t.Errorf("last page should have empty nextLink, got %q", resp.NextLink) + } + if len(resp.Value) != 5 { + t.Errorf("last partial page size = %d, want 5", len(resp.Value)) + } +} + +func TestCursor_Walk(t *testing.T) { + mux := newMux(t) + cursor := "" + seen := map[int]bool{} + for steps := 0; steps < 20; steps++ { + query := "limit=10&total=25" + if cursor != "" { + query += "&cursor=" + cursor + } + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/pagination/cursor?"+query, nil)) + if w.Code != http.StatusOK { + t.Fatalf("step %d status %d (body=%s)", steps, w.Code, w.Body.String()) + } + var resp CursorResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + for _, item := range resp.Items { + if seen[item.ID] { + t.Errorf("step %d duplicate item id %d", steps, item.ID) + } + seen[item.ID] = true + } + if resp.NextCursor == "" { + break + } + cursor = resp.NextCursor + } + if len(seen) != 25 { + t.Errorf("walked %d items, want 25 — cursor pagination skipped or duplicated", len(seen)) + } +} + +func TestCursor_OpaqueRoundTrip(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/pagination/cursor?limit=5&total=50", nil)) + var resp CursorResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.NextCursor == "" { + t.Fatal("expected next cursor") + } + // The cursor must be opaque to the client; we only assert it + // round-trips and advances the offset. + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, httptest.NewRequest(http.MethodGet, + "/v1/pagination/cursor?limit=5&total=50&cursor="+resp.NextCursor, nil)) + var resp2 CursorResponse + _ = json.Unmarshal(w2.Body.Bytes(), &resp2) + if resp2.Items[0].ID != 5 { + t.Errorf("cursor did not advance: first id = %d, want 5", resp2.Items[0].ID) + } +} + +func TestCursor_Malformed(t *testing.T) { + mux := newMux(t) + cases := []string{ + "%21%21%21", + "not-base64!", + "AAAA", // valid base64 but content is three nul bytes — Atoi fails + } + for _, c := range cases { + t.Run(c, func(t *testing.T) { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/v1/pagination/cursor?cursor="+c, nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400 (body=%s)", w.Code, w.Body.String()) + } + }) + } +} + +func TestDeterministicValue_StableAcrossStyles(t *testing.T) { + mux := newMux(t) + // Pull item id=7 from each style. + get := func(t *testing.T, path string) Item { + t.Helper() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + var raw map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil { + t.Fatalf("unmarshal %s: %v", path, err) + } + var items []Item + if data, ok := raw["items"]; ok { + _ = json.Unmarshal(data, &items) + } else if data, ok := raw["value"]; ok { + _ = json.Unmarshal(data, &items) + } + for _, it := range items { + if it.ID == 7 { + return it + } + } + t.Fatalf("id 7 not in %s body", path) + return Item{} + } + a := get(t, "/v1/pagination/link?page=1&per_page=10&total=10") + b := get(t, "/v1/pagination/odata?$top=10&$skip=0&total=10") + c := get(t, "/v1/pagination/cursor?limit=10&total=10") + if a.Value != b.Value || b.Value != c.Value { + t.Errorf("id 7 value differs across styles: link=%q odata=%q cursor=%q", + a.Value, b.Value, c.Value) + } +} + +func TestValidation(t *testing.T) { + mux := newMux(t) + cases := []struct { + path string + status int + }{ + {"/v1/pagination/link?page=0", http.StatusBadRequest}, + {"/v1/pagination/link?page=abc", http.StatusBadRequest}, + {"/v1/pagination/link?per_page=99999", http.StatusBadRequest}, + {"/v1/pagination/link?total=99999", http.StatusBadRequest}, + {"/v1/pagination/odata?$top=0", http.StatusBadRequest}, + {"/v1/pagination/odata?$skip=-1", http.StatusBadRequest}, + {"/v1/pagination/cursor?limit=0", http.StatusBadRequest}, + {"/v1/pagination/link", http.StatusOK}, + {"/v1/pagination/odata", http.StatusOK}, + {"/v1/pagination/cursor", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.path, func(t *testing.T) { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, c.path, nil)) + if w.Code != c.status { + t.Errorf("status %d, want %d (body=%s)", w.Code, c.status, w.Body.String()) + } + }) + } +} + +func TestRoutes_Shape(t *testing.T) { + routes := New().Routes() + if len(routes) != 3 { + t.Fatalf("got %d routes, want 3", len(routes)) + } + for _, r := range routes { + if r.Group != groupName { + t.Errorf("%s Group = %q", r.Path, r.Group) + } + if r.QueryParams == nil { + t.Errorf("%s missing QueryParams", r.Path) + } + if r.ResponseBody == nil { + t.Errorf("%s missing ResponseBody", r.Path) + } + } +} + +func TestEncodeDecodeCursor_RoundTrip(t *testing.T) { + for _, n := range []int{0, 1, 10, 999, 12345} { + enc := encodeCursor(n) + got, err := decodeCursor(enc) + if err != nil { + t.Errorf("decode %q: %v", enc, err) + continue + } + if got != n { + t.Errorf("round trip %d → %q → %d", n, enc, got) + } + } +} diff --git a/pkg/endpoints/security/security.go b/pkg/endpoints/security/security.go new file mode 100644 index 0000000..dae7bb5 --- /dev/null +++ b/pkg/endpoints/security/security.go @@ -0,0 +1,232 @@ +// Package security provides probe endpoints designed to LOOK like +// dangerous gateway targets so the gateway can pattern-match them and +// refuse to forward. The handlers themselves are inert — they never +// fetch a URL, never escalate privileges, never emit smuggling-shaped +// responses. The job is to give a gateway tester a stable set of +// "the gateway should have refused this" probes. +// +// Probes: +// +// - GET /v1/security/admin/secret — privileged-looking path. +// - GET /v1/security/fetch?url=... — SSRF-shape input. +// - GET /v1/security/big-headers — 32 KiB of response headers. +// - POST /v1/security/redirect-to?url=... — open-redirect shape. +// - GET /v1/security/control-chars?q=... — control chars in query. +// +// Each handler returns a small JSON body so a tester can observe both +// the gateway's refusal (404/451/etc from the gateway) and the api-test +// response shape if the gateway forwards regardless. +package security + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const ( + groupName = "security" + + // bigHeaderCount + bigHeaderValueLen yields ~32 KiB of response + // headers; gateway header-size caps should reject this if the + // gateway enforces RFC 7230 §3.2.5 sensibly. + bigHeaderCount = 64 + bigHeaderValueLen = 512 +) + +// Group implements endpoints.Endpoints for the security group. +type Group struct{} + +// New returns a Group. +func New() *Group { return &Group{} } + +// Name implements endpoints.Endpoints. +func (Group) Name() string { return groupName } + +// Routes implements endpoints.Endpoints. +func (Group) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{ + { + Name: "security_admin", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/security/admin/secret", + Description: "Privileged-looking path. A gateway with path filtering should refuse to forward.", + ResponseBody: (*AdminResponse)(nil), + }, + { + Name: "security_fetch", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/security/fetch", + Description: "SSRF-shape input (?url=...). The handler does NOT fetch; it echoes the URL asked. A gateway with SSRF heuristics should refuse to forward when url points at localhost, link-local addresses, or non-allowlisted hosts.", + QueryParams: (*FetchQuery)(nil), + ResponseBody: (*FetchResponse)(nil), + }, + { + Name: "security_big_headers", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/security/big-headers", + Description: "Emits ~32 KiB of response headers. A gateway with response-header-size limits should reject or rewrite.", + ResponseBody: (*BigHeadersResponse)(nil), + }, + { + Name: "security_redirect_to", + Group: groupName, + Method: http.MethodPost, + Path: "/v1/security/redirect-to", + Description: "Open-redirect shape: returns 200 + X-Would-Redirect-To header carrying the caller's ?url. NOT a real Location header (and NOT a 3xx status) — the probe is inert by design. Gateway URL filters that scan arbitrary response headers can still pattern-match.", + QueryParams: (*RedirectQuery)(nil), + ResponseBody: (*RedirectResponse)(nil), + }, + { + Name: "security_control_chars", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/security/control-chars", + Description: "Echoes ?q= back in the response body with any control characters intact. A gateway that sanitizes by replacing or stripping control bytes will produce a different body than the one written here.", + QueryParams: (*ControlCharsQuery)(nil), + ResponseBody: (*ControlCharsResponse)(nil), + }, + } +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/security/admin/secret", mw(http.HandlerFunc(g.admin))) + mux.Handle("GET /v1/security/fetch", mw(http.HandlerFunc(g.fetch))) + mux.Handle("GET /v1/security/big-headers", mw(http.HandlerFunc(g.bigHeaders))) + mux.Handle("POST /v1/security/redirect-to", mw(http.HandlerFunc(g.redirectTo))) + mux.Handle("GET /v1/security/control-chars", mw(http.HandlerFunc(g.controlChars))) +} + +// AdminResponse is the body of /v1/security/admin/secret. +type AdminResponse struct { + Message string `json:"message"` +} + +// FetchQuery is the query shape for /v1/security/fetch. +type FetchQuery struct { + URL string `json:"url"` +} + +// FetchResponse is the body of /v1/security/fetch. +type FetchResponse struct { + AskedFor string `json:"asked_for"` + WouldHaveFetched bool `json:"would_have_fetched"` +} + +// BigHeadersResponse is the body of /v1/security/big-headers. +type BigHeadersResponse struct { + HeaderCount int `json:"header_count"` + HeaderBytes int `json:"header_bytes"` +} + +// RedirectQuery is the query shape for /v1/security/redirect-to. +type RedirectQuery struct { + URL string `json:"url"` +} + +// RedirectResponse is the body of /v1/security/redirect-to. +type RedirectResponse struct { + WouldRedirectTo string `json:"would_redirect_to"` +} + +// ControlCharsQuery is the query shape for /v1/security/control-chars. +type ControlCharsQuery struct { + Q string `json:"q"` +} + +// ControlCharsResponse is the body of /v1/security/control-chars. +type ControlCharsResponse struct { + Q string `json:"q"` + ByteCount int `json:"byte_count"` + HasControl bool `json:"has_control"` +} + +func (g *Group) admin(w http.ResponseWriter, _ *http.Request) { + writeJSONOK(w, AdminResponse{ + Message: "This endpoint exists to be a probe target. A correctly-configured gateway should have refused to forward this request before it reached api-test.", + }) +} + +func (g *Group) fetch(w http.ResponseWriter, r *http.Request) { + url := r.URL.Query().Get("url") + if url == "" { + writeBadRequest(w, "url must be provided (this endpoint is a probe; it does not actually fetch)") + return + } + writeJSONOK(w, FetchResponse{ + AskedFor: url, + WouldHaveFetched: false, + }) +} + +func (g *Group) bigHeaders(w http.ResponseWriter, _ *http.Request) { + value := strings.Repeat("A", bigHeaderValueLen) + totalBytes := 0 + for i := 0; i < bigHeaderCount; i++ { + name := "X-Big-Probe-" + strconv.Itoa(i) + w.Header().Set(name, value) + totalBytes += len(name) + len(value) + 4 // ": " + CRLF + } + writeJSONOK(w, BigHeadersResponse{ + HeaderCount: bigHeaderCount, + HeaderBytes: totalBytes, + }) +} + +func (g *Group) redirectTo(w http.ResponseWriter, r *http.Request) { + url := r.URL.Query().Get("url") + if url == "" { + writeBadRequest(w, "url must be provided") + return + } + // We deliberately use a custom X-Would-Redirect-To header rather + // than Location, and return 200 rather than 3xx. Both choices keep + // the response unambiguously inert: browsers don't auto-follow, + // and CodeQL's go/unvalidated-url-redirection rule isn't triggered + // because we never sink user input into a redirect-recognized + // header. Gateways that scan response headers can still pattern- + // match the X-Would-Redirect-To shape to flag suspect requests. + w.Header().Set("X-Would-Redirect-To", url) + writeJSONOK(w, RedirectResponse{WouldRedirectTo: url}) +} + +func (g *Group) controlChars(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query().Get("q") + writeJSONOK(w, ControlCharsResponse{ + Q: q, + ByteCount: len(q), + HasControl: containsControl(q), + }) +} + +func containsControl(s string) bool { + for _, r := range s { + if r < 0x20 || r == 0x7f { + return true + } + } + return false +} + +// writeJSONOK writes body as application/json with a 200 status. Every +// probe in this package returns 200 (with body distinguishing the +// probe semantics); using a fixed-status helper keeps the lint pass +// happy and makes call sites read at a glance. +func writeJSONOK(w http.ResponseWriter, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(body) +} + +func writeBadRequest(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/pkg/endpoints/security/security_test.go b/pkg/endpoints/security/security_test.go new file mode 100644 index 0000000..2af771c --- /dev/null +++ b/pkg/endpoints/security/security_test.go @@ -0,0 +1,181 @@ +package security + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newMux(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + New().Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestAdmin_AlwaysServes(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/security/admin/secret", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var resp AdminResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Message == "" { + t.Errorf("admin should return a non-empty message body") + } +} + +func TestFetch_DoesNotActuallyFetch(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/v1/security/fetch?url=http://169.254.169.254/latest/meta-data/", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var resp FetchResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.WouldHaveFetched { + t.Errorf("fetch probe must always report would_have_fetched=false; got true") + } + if !strings.Contains(resp.AskedFor, "169.254.169.254") { + t.Errorf("asked_for should echo URL; got %q", resp.AskedFor) + } +} + +func TestFetch_MissingURL(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/security/fetch", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400", w.Code) + } +} + +func TestBigHeaders_EmitsManyHeaders(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/security/big-headers", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + probeCount := 0 + for k := range w.Header() { + if strings.HasPrefix(k, "X-Big-Probe-") { + probeCount++ + } + } + if probeCount != bigHeaderCount { + t.Errorf("got %d probe headers, want %d", probeCount, bigHeaderCount) + } +} + +func TestRedirectTo_Inert(t *testing.T) { + mux := newMux(t) + target := "https://evil.example/landing" + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, + "/v1/security/redirect-to?url="+target, nil)) + + // The probe is inert by design: status 200 (browsers don't auto- + // follow) and a custom header X-Would-Redirect-To (CodeQL's + // go/unvalidated-url-redirection rule does not trace through + // non-Location headers). NEITHER a 3xx status NOR a Location + // header is allowed. + if w.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (must be inert)", w.Code) + } + if loc := w.Header().Get("Location"); loc != "" { + t.Errorf("Location header set to %q — probe must NOT set Location", loc) + } + if got := w.Header().Get("X-Would-Redirect-To"); got != target { + t.Errorf("X-Would-Redirect-To = %q, want %q", got, target) + } + var resp RedirectResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.WouldRedirectTo != target { + t.Errorf("body WouldRedirectTo = %q, want %q", resp.WouldRedirectTo, target) + } +} + +func TestRedirectTo_MissingURL(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/v1/security/redirect-to", nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400", w.Code) + } +} + +func TestControlChars_DetectsControlBytes(t *testing.T) { + mux := newMux(t) + cases := []struct { + query string + wantControl bool + }{ + {"plain", false}, + {"with%00nul", true}, + {"with%0Anewline", true}, + {"with%7Fdel", true}, + {"ascii-only", false}, + } + for _, c := range cases { + t.Run(c.query, func(t *testing.T) { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, + "/v1/security/control-chars?q="+c.query, nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var resp ControlCharsResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if resp.HasControl != c.wantControl { + t.Errorf("HasControl = %v, want %v (q=%q)", resp.HasControl, c.wantControl, resp.Q) + } + if resp.ByteCount != len(resp.Q) { + t.Errorf("ByteCount %d != len(Q) %d", resp.ByteCount, len(resp.Q)) + } + }) + } +} + +func TestContainsControl_KnownBytes(t *testing.T) { + cases := map[string]bool{ + "": false, + "plain ascii": false, + "\x00": true, + "end\x1ftext": true, + "newline\nhere": true, + "tab\there": true, + "\x7f": true, + "emoji \U0001F600": false, + } + for in, want := range cases { + if got := containsControl(in); got != want { + t.Errorf("containsControl(%q) = %v, want %v", in, got, want) + } + } +} + +func TestRoutes_Shape(t *testing.T) { + routes := New().Routes() + if len(routes) != 5 { + t.Fatalf("got %d routes, want 5", len(routes)) + } + for _, r := range routes { + if r.Group != groupName { + t.Errorf("%s group = %q", r.Path, r.Group) + } + if r.ResponseBody == nil { + t.Errorf("%s missing ResponseBody", r.Path) + } + } +} diff --git a/pkg/endpoints/streaming/streaming.go b/pkg/endpoints/streaming/streaming.go new file mode 100644 index 0000000..27b9e22 --- /dev/null +++ b/pkg/endpoints/streaming/streaming.go @@ -0,0 +1,275 @@ +// Package streaming provides controllable streaming-response endpoints +// (chunked, Server-Sent Events, NDJSON). They exist so a gateway test can +// verify that the gateway preserves transfer encoding, flush boundaries, +// and content type across the proxy hop. Same (count, seed) → same body. +package streaming + +import ( + "context" + "encoding/json" + "fmt" + "hash/fnv" + "math/rand/v2" // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used -- intentional: PCG seeded from a caller-supplied string for reproducible test fixtures; crypto/rand would defeat the determinism contract. + "net/http" + "strconv" + "time" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const ( + groupName = "streaming" + + // maxCount bounds chunked/sse/ndjson item count. Larger streams + // belong on the export group, which is built for long bodies. + maxCount = 1000 + + // maxDelayMS bounds the inter-item delay. Long delays exercise + // gateway read timeouts; values beyond this are diminishing + // returns and a foot-gun for CI. + maxDelayMS = 5000 + + // defaultCount is the count returned when the caller omits the + // query parameter. + defaultCount = 5 +) + +// Group implements endpoints.Endpoints for the streaming group. +type Group struct{} + +// New returns a Group. +func New() *Group { return &Group{} } + +// Name implements endpoints.Endpoints. +func (Group) Name() string { return groupName } + +// Routes implements endpoints.Endpoints. +func (Group) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{ + { + Name: "streaming_chunked", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/streaming/chunked", + Description: "Stream N text chunks with Transfer-Encoding: chunked. Each chunk is one line, deterministically generated from (seed, index).", + QueryParams: (*StreamQuery)(nil), + ResponseBody: (*ChunkedResponse)(nil), + }, + { + Name: "streaming_sse", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/streaming/sse", + Description: "Stream N Server-Sent Events (text/event-stream). Each event has id and data fields; data payload is deterministic from (seed, index).", + QueryParams: (*StreamQuery)(nil), + ResponseBody: (*SSEEvent)(nil), + }, + { + Name: "streaming_ndjson", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/streaming/ndjson", + Description: "Stream N newline-delimited JSON objects (application/x-ndjson). Same (count, seed) reproduces the same body.", + QueryParams: (*StreamQuery)(nil), + ResponseBody: (*NDJSONLine)(nil), + }, + } +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/streaming/chunked", mw(http.HandlerFunc(g.chunked))) + mux.Handle("GET /v1/streaming/sse", mw(http.HandlerFunc(g.sse))) + mux.Handle("GET /v1/streaming/ndjson", mw(http.HandlerFunc(g.ndjson))) +} + +// StreamQuery is the shared query-parameter shape for every streaming +// endpoint. Count caps at maxCount; delay caps at maxDelayMS; seed is +// optional and controls content determinism. +type StreamQuery struct { + Count int `json:"count,omitempty"` + DelayMS int `json:"delay_ms,omitempty"` + Seed string `json:"seed,omitempty"` +} + +// ChunkedResponse documents the shape of each chunk in the chunked +// endpoint's stream. The wire format is `text/plain`; this struct +// describes the per-chunk text the reflector emits so the OpenAPI doc +// has something concrete to point at. +type ChunkedResponse struct { + Index int `json:"index"` + Word string `json:"word"` +} + +// SSEEvent documents the shape of one SSE event's data payload. +type SSEEvent struct { + ID int `json:"id"` + Word string `json:"word"` +} + +// NDJSONLine documents the shape of one newline-delimited JSON object. +type NDJSONLine struct { + Index int `json:"index"` + Word string `json:"word"` +} + +// chunked writes count text lines separated by '\n', flushing between +// each. Transfer-Encoding: chunked is set by net/http automatically +// because we don't pre-set Content-Length and we flush. +func (g *Group) chunked(w http.ResponseWriter, r *http.Request) { + q, ok := parseStreamQuery(w, r) + if !ok { + return + } + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + + for i := 0; i < q.Count; i++ { + _, _ = fmt.Fprintf(w, "chunk %d: %s\n", i, deterministicWord(q.Seed, i)) + if flusher != nil { + flusher.Flush() + } + if q.DelayMS > 0 && i < q.Count-1 { + if !waitOrCancel(r.Context(), time.Duration(q.DelayMS)*time.Millisecond) { + return + } + } + } +} + +// sse writes count SSE events. Each event has an explicit id and a JSON +// payload so a gateway can verify event boundaries are preserved through +// proxying. +func (g *Group) sse(w http.ResponseWriter, r *http.Request) { + q, ok := parseStreamQuery(w, r) + if !ok { + return + } + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + + for i := 0; i < q.Count; i++ { + payload, _ := json.Marshal(SSEEvent{ID: i, Word: deterministicWord(q.Seed, i)}) + _, _ = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", i, payload) + if flusher != nil { + flusher.Flush() + } + if q.DelayMS > 0 && i < q.Count-1 { + if !waitOrCancel(r.Context(), time.Duration(q.DelayMS)*time.Millisecond) { + return + } + } + } +} + +// ndjson writes count newline-delimited JSON objects. +func (g *Group) ndjson(w http.ResponseWriter, r *http.Request) { + q, ok := parseStreamQuery(w, r) + if !ok { + return + } + flusher, _ := w.(http.Flusher) + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(http.StatusOK) + + enc := json.NewEncoder(w) + for i := 0; i < q.Count; i++ { + _ = enc.Encode(NDJSONLine{Index: i, Word: deterministicWord(q.Seed, i)}) + if flusher != nil { + flusher.Flush() + } + if q.DelayMS > 0 && i < q.Count-1 { + if !waitOrCancel(r.Context(), time.Duration(q.DelayMS)*time.Millisecond) { + return + } + } + } +} + +// parseStreamQuery extracts and validates count/delay_ms/seed from the +// request. On validation failure it writes a 400 and returns ok=false. +func parseStreamQuery(w http.ResponseWriter, r *http.Request) (StreamQuery, bool) { + q := r.URL.Query() + out := StreamQuery{Seed: q.Get("seed")} + + if raw := q.Get("count"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + writeBadRequest(w, "count must be a non-negative integer") + return out, false + } + if n > maxCount { + writeBadRequest(w, fmt.Sprintf("count %d exceeds max %d", n, maxCount)) + return out, false + } + out.Count = n + } else { + out.Count = defaultCount + } + + if raw := q.Get("delay_ms"); raw != "" { + ms, err := strconv.Atoi(raw) + if err != nil || ms < 0 { + writeBadRequest(w, "delay_ms must be a non-negative integer") + return out, false + } + if ms > maxDelayMS { + writeBadRequest(w, fmt.Sprintf("delay_ms %d exceeds max %d", ms, maxDelayMS)) + return out, false + } + out.DelayMS = ms + } + return out, true +} + +// deterministicWord returns a stable word for the (seed, index) pair so +// callers can replay a stream and bit-compare. Re-seeded per call so +// requesting index 7 in a 100-item stream returns the same word it would +// in a 10-item stream. +func deterministicWord(seed string, index int) string { + combined := fmt.Sprintf("%s:%d", seed, index) + h := fnv.New64a() + _, _ = h.Write([]byte(combined)) + sum := h.Sum64() + rng := rand.New(rand.NewPCG(sum, sum>>1)) //#nosec G404 -- see package import note + return dict[rng.IntN(len(dict))] +} + +// dict is a fixed word list so the deterministic generator's output is +// readable and comparable across builds. Same word at the same index +// for any given seed. +var dict = []string{ + "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", + "golf", "hotel", "india", "juliet", "kilo", "lima", + "mike", "november", "oscar", "papa", "quebec", "romeo", + "sierra", "tango", "uniform", "victor", "whiskey", "xray", + "yankee", "zulu", +} + +// waitOrCancel sleeps for d or returns early when ctx is cancelled (the +// usual cause: client disconnect). Reports true when the wait completed +// normally, false when the context cancelled — the caller stops streaming +// so we don't sit on a goroutine the client no longer cares about. +func waitOrCancel(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-timer.C: + return true + case <-ctx.Done(): + return false + } +} + +// writeBadRequest writes a 400 JSON error. Streaming endpoints reject +// malformed input before opening the stream, so this is the only error +// path; using a fixed-status helper keeps the lint pass happy and makes +// the call sites read at a glance. +func writeBadRequest(w http.ResponseWriter, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/pkg/endpoints/streaming/streaming_test.go b/pkg/endpoints/streaming/streaming_test.go new file mode 100644 index 0000000..19f2986 --- /dev/null +++ b/pkg/endpoints/streaming/streaming_test.go @@ -0,0 +1,240 @@ +package streaming + +import ( + "bufio" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newMux(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + New().Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestChunked_Default(t *testing.T) { + mux := newMux(t) + req := httptest.NewRequest(http.MethodGet, "/v1/streaming/chunked", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("Content-Type = %q", ct) + } + lines := strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") + if len(lines) != defaultCount { + t.Errorf("got %d lines, want %d (body=%q)", len(lines), defaultCount, w.Body.String()) + } + if !strings.HasPrefix(lines[0], "chunk 0:") { + t.Errorf("first line shape wrong: %q", lines[0]) + } +} + +func TestChunked_Deterministic(t *testing.T) { + mux := newMux(t) + body1 := getBody(t, mux, "/v1/streaming/chunked?count=10&seed=fixed") + body2 := getBody(t, mux, "/v1/streaming/chunked?count=10&seed=fixed") + if body1 != body2 { + t.Errorf("same (count, seed) produced different bodies:\nA=%q\nB=%q", body1, body2) + } + // Different seed must produce different content. + body3 := getBody(t, mux, "/v1/streaming/chunked?count=10&seed=other") + if body1 == body3 { + t.Errorf("different seed produced same body") + } +} + +func TestSSE_EventShape(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/streaming/sse?count=3&seed=x", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" { + t.Errorf("Content-Type = %q", ct) + } + + body := w.Body.String() + // Three events, each "id: N\ndata: {...}\n\n". + parts := strings.Split(strings.TrimRight(body, "\n"), "\n\n") + if len(parts) != 3 { + t.Fatalf("got %d events, want 3 (body=%q)", len(parts), body) + } + for i, p := range parts { + s := bufio.NewScanner(strings.NewReader(p)) + var idLine, dataLine string + for s.Scan() { + line := s.Text() + switch { + case strings.HasPrefix(line, "id: "): + idLine = strings.TrimPrefix(line, "id: ") + case strings.HasPrefix(line, "data: "): + dataLine = strings.TrimPrefix(line, "data: ") + } + } + wantID := []string{"0", "1", "2"}[i] + if idLine != wantID { + t.Errorf("event %d id = %q, want %q", i, idLine, wantID) + } + var ev SSEEvent + if err := json.Unmarshal([]byte(dataLine), &ev); err != nil { + t.Errorf("event %d data not valid JSON: %v (data=%q)", i, err, dataLine) + } + if ev.ID != i { + t.Errorf("event %d JSON ID = %d", i, ev.ID) + } + } +} + +func TestNDJSON_OneObjectPerLine(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/streaming/ndjson?count=4&seed=q", nil)) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/x-ndjson" { + t.Errorf("Content-Type = %q", ct) + } + lines := strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") + if len(lines) != 4 { + t.Fatalf("got %d lines, want 4 (body=%q)", len(lines), w.Body.String()) + } + for i, line := range lines { + var got NDJSONLine + if err := json.Unmarshal([]byte(line), &got); err != nil { + t.Errorf("line %d not JSON: %v (%q)", i, err, line) + continue + } + if got.Index != i { + t.Errorf("line %d Index = %d", i, got.Index) + } + } +} + +func TestCountValidation(t *testing.T) { + mux := newMux(t) + cases := []struct { + query string + status int + }{ + {"count=abc", http.StatusBadRequest}, + {"count=-1", http.StatusBadRequest}, + {"count=99999", http.StatusBadRequest}, + {"delay_ms=abc", http.StatusBadRequest}, + {"delay_ms=-1", http.StatusBadRequest}, + {"delay_ms=99999", http.StatusBadRequest}, + {"count=0", http.StatusOK}, + {"count=5", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.query, func(t *testing.T) { + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/streaming/chunked?"+c.query, nil)) + if w.Code != c.status { + t.Errorf("status %d, want %d (body=%s)", w.Code, c.status, w.Body.String()) + } + }) + } +} + +func TestCountZero_EmptyBody(t *testing.T) { + mux := newMux(t) + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/streaming/ndjson?count=0", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("count=0 should produce empty body, got %q", w.Body.String()) + } +} + +func TestContextCancelStopsStream(t *testing.T) { + mux := newMux(t) + // count=10, delay_ms=200 would take ~1.8s normally. Cancel after + // 50ms and the handler must return promptly. + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + req := httptest.NewRequest(http.MethodGet, "/v1/streaming/chunked?count=10&delay_ms=200", nil).WithContext(ctx) + w := httptest.NewRecorder() + start := time.Now() + mux.ServeHTTP(w, req) + elapsed := time.Since(start) + + // Allow generous slack for CI; the bound matters, not the exact + // time. With the bug the handler ran for ~1.8s. + if elapsed > 500*time.Millisecond { + t.Errorf("handler did not honor context cancel: ran for %v", elapsed) + } +} + +func TestDeterministicWord_StableAcrossCalls(t *testing.T) { + a := deterministicWord("seed", 42) + b := deterministicWord("seed", 42) + if a != b { + t.Errorf("same input produced different output: %q vs %q", a, b) + } + c := deterministicWord("other", 42) + if a == c { + t.Errorf("different seed produced same word: %q", a) + } +} + +func TestRoutes_RegisteredShape(t *testing.T) { + routes := New().Routes() + if len(routes) != 3 { + t.Fatalf("got %d routes, want 3", len(routes)) + } + want := map[string]bool{ + "GET /v1/streaming/chunked": false, + "GET /v1/streaming/sse": false, + "GET /v1/streaming/ndjson": false, + } + for _, r := range routes { + key := r.Method + " " + r.Path + if _, ok := want[key]; !ok { + t.Errorf("unexpected route: %s", key) + continue + } + want[key] = true + if r.Group != groupName { + t.Errorf("route %s Group = %q, want %q", key, r.Group, groupName) + } + if r.QueryParams == nil { + t.Errorf("route %s missing QueryParams (OpenAPI reflection needs this)", key) + } + if r.ResponseBody == nil { + t.Errorf("route %s missing ResponseBody", key) + } + } + for k, found := range want { + if !found { + t.Errorf("missing route %s", k) + } + } +} + +func getBody(t *testing.T, mux *http.ServeMux, path string) string { + t.Helper() + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("status %d for %s (body=%s)", w.Code, path, w.Body.String()) + } + return w.Body.String() +} diff --git a/pkg/httpmw/audit.go b/pkg/httpmw/audit.go index d55bff2..a3f7511 100644 --- a/pkg/httpmw/audit.go +++ b/pkg/httpmw/audit.go @@ -107,6 +107,11 @@ func Audit(logger audit.Logger, registry *endpoints.Registry, slogger *slog.Logg ResponseTruncated: rec.truncated, ResponseContentType: rec.Header().Get("Content-Type"), ResponseBody: rec.body.Bytes(), + // If this request came in through the portal's + // /audit/replay handler, the replay marker + // header carries the original event's ID. + // Recording it links the replay to its source. + ReplayedFrom: r.Header.Get(audit.ReplayHeaderName), } if opts.CaptureHeaders { p.RequestHeaders = audit.SanitizeHeaders(r.Header, opts.RedactKeys) diff --git a/pkg/httpsrv/docs.go b/pkg/httpsrv/docs.go new file mode 100644 index 0000000..8f0baac --- /dev/null +++ b/pkg/httpsrv/docs.go @@ -0,0 +1,36 @@ +package httpsrv + +import "net/http" + +// docsHTML is the Redoc viewer for /openapi.json. The Redoc bundle is +// pulled from the unpkg CDN; api-test is a developer-facing fixture so +// the extra dependency is acceptable. Operators who need an offline page +// can override /docs upstream. +const docsHTML = ` + + + + api-test — API reference + + + + + + + + + + +` + +// docsHandler serves the embedded Redoc page that points at /openapi.json. +func docsHandler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(docsHTML)) + } +} diff --git a/pkg/httpsrv/docs_test.go b/pkg/httpsrv/docs_test.go new file mode 100644 index 0000000..a5ab0f8 --- /dev/null +++ b/pkg/httpsrv/docs_test.go @@ -0,0 +1,36 @@ +package httpsrv + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" + "github.com/plexara/api-test/pkg/oapi" +) + +func TestBuildMux_Docs(t *testing.T) { + reg := endpoints.NewRegistry() + reg.Add(stubGroup{}) + doc := oapi.Build(reg, oapi.BuildOptions{Info: oapi.Info{Title: "t", Version: "v0"}}) + mux, err := BuildMux(reg, NewReadiness(), nil, nil, &doc) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/docs", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("Content-Type = %q", ct) + } + body := w.Body.String() + if !strings.Contains(body, `spec-url="/openapi.json"`) { + t.Errorf("docs HTML does not point at /openapi.json:\n%s", body) + } +} diff --git a/pkg/httpsrv/mux.go b/pkg/httpsrv/mux.go index 993474e..90ecd9e 100644 --- a/pkg/httpsrv/mux.go +++ b/pkg/httpsrv/mux.go @@ -7,6 +7,7 @@ import ( "github.com/plexara/api-test/pkg/config" "github.com/plexara/api-test/pkg/endpoints" + "github.com/plexara/api-test/pkg/oapi" ) // PortalDeps bundles everything needed to mount the portal under /portal/ @@ -22,11 +23,21 @@ type PortalDeps struct { // BuildMux assembles the HTTP mux: // - /healthz, /readyz // - /v1/* endpoint groups (with the supplied middleware) +// - /openapi.json, /openapi.yaml when oapiDoc != nil // - /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server // - /portal/, /portal/api/*, /portal/auth/{login,callback,logout} when portal != nil // - / — root handler returning a JSON banner (or a redirect to the portal // when the request looks like a browser GET) -func BuildMux(registry *endpoints.Registry, readiness *Readiness, mw endpoints.Middleware, portal *PortalDeps) http.Handler { +// +// Returns an error only when oapiDoc rendering fails; callers that pass +// nil get a nil error. +func BuildMux( + registry *endpoints.Registry, + readiness *Readiness, + mw endpoints.Middleware, + portal *PortalDeps, + oapiDoc *oapi.Document, +) (http.Handler, error) { mux := http.NewServeMux() mux.HandleFunc("GET /healthz", HealthzHandler()) @@ -37,6 +48,12 @@ func BuildMux(registry *endpoints.Registry, readiness *Readiness, mw endpoints.M } registry.Mount(mux, mw) + if oapiDoc != nil { + if err := mountOpenAPI(mux, *oapiDoc); err != nil { + return nil, err + } + } + if portal != nil { mux.HandleFunc("GET /.well-known/oauth-protected-resource", ProtectedResourceMetadata(portal.Cfg)) mux.HandleFunc("GET /.well-known/oauth-authorization-server", AuthorizationServerStub(portal.Cfg)) @@ -58,7 +75,7 @@ func BuildMux(registry *endpoints.Registry, readiness *Readiness, mw endpoints.M // banner but a browser visit lands on the SPA. handler = BrowserRedirect("/portal/", handler) } - return CORS(handler) + return CORS(handler), nil } // spaOrStub serves the SPA when spaFS is non-nil; otherwise emits a small @@ -93,8 +110,11 @@ func rootHandler(registry *endpoints.Registry, portalEnabled bool) http.HandlerF groups = append(groups, g.Name()) } links := map[string]string{ - "healthz": "/healthz", - "readyz": "/readyz", + "healthz": "/healthz", + "readyz": "/readyz", + "openapi_json": "/openapi.json", + "openapi_yaml": "/openapi.yaml", + "docs": "/docs", } if portalEnabled { links["portal"] = "/portal/" diff --git a/pkg/httpsrv/mux_test.go b/pkg/httpsrv/mux_test.go index e737438..a117076 100644 --- a/pkg/httpsrv/mux_test.go +++ b/pkg/httpsrv/mux_test.go @@ -24,7 +24,10 @@ func (stubGroup) Mount(mux *http.ServeMux, mw endpoints.Middleware) { func TestBuildMux_RootBanner(t *testing.T) { r := endpoints.NewRegistry() r.Add(stubGroup{}) - mux := BuildMux(r, NewReadiness(), nil, nil) + mux, err := BuildMux(r, NewReadiness(), nil, nil, nil) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() @@ -43,7 +46,10 @@ func TestBuildMux_RootBanner(t *testing.T) { } func TestBuildMux_Healthz(t *testing.T) { - mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil, nil) + mux, err := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil, nil, nil) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } req := httptest.NewRequest(http.MethodGet, "/healthz", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -55,7 +61,10 @@ func TestBuildMux_Healthz(t *testing.T) { func TestBuildMux_GroupMounted(t *testing.T) { r := endpoints.NewRegistry() r.Add(stubGroup{}) - mux := BuildMux(r, NewReadiness(), nil, nil) + mux, err := BuildMux(r, NewReadiness(), nil, nil, nil) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } req := httptest.NewRequest(http.MethodGet, "/v1/ping", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) @@ -65,7 +74,10 @@ func TestBuildMux_GroupMounted(t *testing.T) { } func TestBuildMux_404(t *testing.T) { - mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil, nil) + mux, err := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil, nil, nil) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } req := httptest.NewRequest(http.MethodGet, "/no-such-thing", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) diff --git a/pkg/httpsrv/openapi.go b/pkg/httpsrv/openapi.go new file mode 100644 index 0000000..9464b42 --- /dev/null +++ b/pkg/httpsrv/openapi.go @@ -0,0 +1,39 @@ +package httpsrv + +import ( + "fmt" + "net/http" + + "github.com/plexara/api-test/pkg/oapi" +) + +// mountOpenAPI mounts GET /openapi.json and GET /openapi.yaml backed by +// the pre-rendered document. Rendering happens once at boot; the registry +// is fixed after composition, so the bytes are valid for the process +// lifetime. +// +// Returns an error if either rendering step fails so server boot can +// surface the problem instead of silently serving 500s. +func mountOpenAPI(mux *http.ServeMux, doc oapi.Document) error { + jsonBytes, err := oapi.RenderJSON(doc) + if err != nil { + return fmt.Errorf("render openapi.json: %w", err) + } + yamlBytes, err := oapi.RenderYAML(doc) + if err != nil { + return fmt.Errorf("render openapi.yaml: %w", err) + } + mux.HandleFunc("GET /openapi.json", serveBytes("application/json", jsonBytes)) + mux.HandleFunc("GET /openapi.yaml", serveBytes("application/yaml", yamlBytes)) + mux.HandleFunc("GET /docs", docsHandler()) + return nil +} + +func serveBytes(contentType string, body []byte) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + } +} diff --git a/pkg/httpsrv/openapi_test.go b/pkg/httpsrv/openapi_test.go new file mode 100644 index 0000000..200eac2 --- /dev/null +++ b/pkg/httpsrv/openapi_test.go @@ -0,0 +1,86 @@ +package httpsrv + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" + "github.com/plexara/api-test/pkg/oapi" +) + +func TestBuildMux_OpenAPI_JSON(t *testing.T) { + reg := endpoints.NewRegistry() + reg.Add(stubGroup{}) + doc := oapi.Build(reg, oapi.BuildOptions{ + Info: oapi.Info{Title: "api-test", Version: "v0"}, + Servers: []oapi.Server{{URL: "http://localhost:8080"}}, + }) + mux, err := BuildMux(reg, NewReadiness(), nil, nil, &doc) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/openapi.json", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("Content-Type = %q", ct) + } + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body["openapi"] != "3.1.0" { + t.Errorf("openapi = %v", body["openapi"]) + } + paths, _ := body["paths"].(map[string]any) + if paths["/v1/ping"] == nil { + t.Errorf("missing /v1/ping in paths: %v", paths) + } +} + +func TestBuildMux_OpenAPI_YAML(t *testing.T) { + reg := endpoints.NewRegistry() + reg.Add(stubGroup{}) + doc := oapi.Build(reg, oapi.BuildOptions{Info: oapi.Info{Title: "t", Version: "v0"}}) + mux, err := BuildMux(reg, NewReadiness(), nil, nil, &doc) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/yaml" { + t.Errorf("Content-Type = %q", ct) + } + if !strings.Contains(w.Body.String(), "openapi: 3.1.0") { + t.Errorf("YAML missing openapi field; body:\n%s", w.Body.String()) + } +} + +func TestBuildMux_OpenAPI_DisabledWhenNil(t *testing.T) { + reg := endpoints.NewRegistry() + reg.Add(stubGroup{}) + mux, err := BuildMux(reg, NewReadiness(), nil, nil, nil) + if err != nil { + t.Fatalf("BuildMux: %v", err) + } + req := httptest.NewRequest(http.MethodGet, "/openapi.json", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code == http.StatusOK { + t.Errorf("status = 200, openapi should be off when nil doc passed") + } +} diff --git a/pkg/httpsrv/portal_api.go b/pkg/httpsrv/portal_api.go index 6c666c3..77f053b 100644 --- a/pkg/httpsrv/portal_api.go +++ b/pkg/httpsrv/portal_api.go @@ -29,10 +29,11 @@ import ( // CSRF header check on top of auth so a forged
POST cannot reach // them via SameSite=Lax cookies alone. type PortalAPI struct { - cfg *config.Config - registry *endpoints.Registry - audit audit.Logger - keys *apikeys.Store // nil if config.APIKeys.DB.Enabled=false + cfg *config.Config + registry *endpoints.Registry + audit audit.Logger + keys *apikeys.Store // nil if config.APIKeys.DB.Enabled=false + replayTarget http.Handler // nil disables /audit/replay and /tryit } // NewPortalAPI returns the API. keys may be nil when the DB-backed key store @@ -46,8 +47,36 @@ func NewPortalAPI( return &PortalAPI{cfg: cfg, registry: registry, audit: auditLog, keys: keys} } +// WithReplayTarget enables the audit replay and Try-It endpoints, +// dispatching requests through h. h is the mux *before* the wrapping +// access-log / request-id / browser-redirect / CORS layers — that way +// dispatched requests go straight to the audit middleware and the +// registered routes without recursing through the portal API itself. +// Returns the receiver so the composition can chain. +func (p *PortalAPI) WithReplayTarget(h http.Handler) *PortalAPI { + p.replayTarget = h + return p +} + +// WithDispatchTarget is an alias for WithReplayTarget kept for call +// sites added before the field rename. Use WithReplayTarget for new +// code; this shim will be removed once external callers update. +func (p *PortalAPI) WithDispatchTarget(h http.Handler) *PortalAPI { + return p.WithReplayTarget(h) +} + // Mount adds every endpoint behind the supplied auth middleware. +// +// As a side effect, the supplied mux becomes the default replay/dispatch +// target for /audit/replay and /tryit if WithReplayTarget hasn't already +// been called. The mux at the time those endpoints are invoked has every +// /v1/* route mounted on it (BuildMux mounts those before calling +// PortalAPI.Mount), so dispatched/replayed requests go straight into +// the audit-wrapped endpoint handlers and show up as new audit rows. func (p *PortalAPI) Mount(mux *http.ServeMux, mw func(http.Handler) http.Handler) { + if p.replayTarget == nil { + p.replayTarget = mux + } wrap := func(h http.Handler) http.Handler { return mw(requireCSRFHeader(h)) } mux.Handle("GET /api/v1/portal/me", mw(http.HandlerFunc(p.me))) @@ -61,6 +90,14 @@ func (p *PortalAPI) Mount(mux *http.ServeMux, mw func(http.Handler) http.Handler mux.Handle("GET /api/v1/portal/audit/meta", mw(http.HandlerFunc(p.auditMeta))) mux.Handle("GET /api/v1/portal/audit/events", mw(http.HandlerFunc(p.auditEvents))) mux.Handle("GET /api/v1/portal/audit/events/{id}", mw(http.HandlerFunc(p.auditEventDetail))) + mux.Handle("GET /api/v1/portal/audit/timeseries", mw(http.HandlerFunc(p.auditTimeseries))) + mux.Handle("GET /api/v1/portal/audit/breakdown", mw(http.HandlerFunc(p.auditBreakdown))) + mux.Handle("GET /api/v1/portal/audit/stats", mw(http.HandlerFunc(p.auditStats))) + mux.Handle("GET /api/v1/portal/audit/stream", mw(http.HandlerFunc(p.auditStream))) + mux.Handle("GET /api/v1/portal/audit/export.ndjson", mw(http.HandlerFunc(p.auditExportNDJSON))) + mux.Handle("POST /api/v1/portal/audit/replay/{id}", wrap(http.HandlerFunc(p.auditReplay))) + + mux.Handle("POST /api/v1/portal/tryit/{group}/{route}", wrap(http.HandlerFunc(p.tryIt))) mux.Handle("GET /api/v1/admin/keys", mw(http.HandlerFunc(p.listKeys))) mux.Handle("POST /api/v1/admin/keys", wrap(http.HandlerFunc(p.createKey))) @@ -104,18 +141,24 @@ func (p *PortalAPI) endpointDetail(w http.ResponseWriter, r *http.Request) { } // auditMeta exposes the filter contract surface the SPA's audit filter -// editor uses. Dashboard timeseries / breakdown / SSE / export / replay -// are M3+ features; the SPA detects their absence via the `features` map -// and disables matching panels. +// editor uses. The features map tells the SPA which optional panels to +// enable; the SPA disables matching panels when a flag is false. func (p *PortalAPI) auditMeta(w http.ResponseWriter, _ *http.Request) { + // Replay needs three things: a wired target handler, an audit + // Logger that persists payloads, and (in practice) capture being + // enabled at the time the original event was recorded. We can + // check the first two here; the third is a runtime property the + // handler surfaces as 404. SPA can only check the static flag. + _, payloadCapable := p.audit.(audit.PayloadLogger) writeJSON(w, http.StatusOK, map[string]any{ "filters": []string{"from", "to", "method", "path", "route_name", "status", "user", "session", "success", "q"}, "features": map[string]bool{ - "timeseries": false, - "breakdown": false, - "stream": false, - "export": false, - "replay": false, + "timeseries": true, + "breakdown": true, + "stats": true, + "stream": true, + "export": true, + "replay": p.replayTarget != nil && payloadCapable, }, }) } diff --git a/pkg/httpsrv/portal_audit_aggregations.go b/pkg/httpsrv/portal_audit_aggregations.go new file mode 100644 index 0000000..1716243 --- /dev/null +++ b/pkg/httpsrv/portal_audit_aggregations.go @@ -0,0 +1,341 @@ +package httpsrv + +import ( + "fmt" + "net/http" + "sort" + "strconv" + "time" + + "github.com/plexara/api-test/pkg/audit" +) + +// auditTimeseriesHandler returns bucketed counts and latency for the +// requested time window. Aggregation runs in Go against the audit +// Logger's Query results — fine for a test fixture's traffic. Bucket +// granularity defaults to 60s; clamped to [1, 3600]. +// +// Response shape: +// +// { +// "from": "2026-05-10T00:00:00Z", +// "to": "2026-05-10T01:00:00Z", +// "bucket_seconds": 60, +// "buckets": [{ +// "time": "2026-05-10T00:00:00Z", +// "count": 42, +// "errors": 3, +// "avg_duration_ms": 18.3 +// }] +// } +func (p *PortalAPI) auditTimeseries(w http.ResponseWriter, r *http.Request) { + f, from, to := timeWindow(r) + bucket := bucketSeconds(r) + + events, err := p.audit.Query(r.Context(), f) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + bucketDur := time.Duration(bucket) * time.Second + totalBuckets := int(to.Sub(from)/bucketDur) + 1 + if totalBuckets < 1 { + totalBuckets = 1 + } + if totalBuckets > 10_000 { + // Cap UI-generated runaway requests; the SPA's resolution + // picker never crosses this even at 1-second buckets over + // a 24-hour window. + writeError(w, http.StatusBadRequest, fmt.Errorf("bucket_seconds %d × window produces %d buckets (max 10000); pick a coarser bucket or narrower window", bucket, totalBuckets)) + return + } + + type aggregate struct { + Count int + Errors int + DurationMS int64 + } + bucketsAgg := make([]aggregate, totalBuckets) + for _, ev := range events { + if ev.Timestamp.Before(from) || !ev.Timestamp.Before(to) { + continue + } + idx := int(ev.Timestamp.Sub(from) / bucketDur) + if idx < 0 || idx >= totalBuckets { + continue + } + bucketsAgg[idx].Count++ + bucketsAgg[idx].DurationMS += ev.DurationMS + if !ev.Success { + bucketsAgg[idx].Errors++ + } + } + + type bucketOut struct { + Time time.Time `json:"time"` + Count int `json:"count"` + Errors int `json:"errors"` + AvgDurationMS float64 `json:"avg_duration_ms"` + } + out := make([]bucketOut, 0, totalBuckets) + for i, agg := range bucketsAgg { + var avg float64 + if agg.Count > 0 { + avg = float64(agg.DurationMS) / float64(agg.Count) + } + out = append(out, bucketOut{ + Time: from.Add(time.Duration(i) * bucketDur).UTC(), + Count: agg.Count, + Errors: agg.Errors, + AvgDurationMS: round1(avg), + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "from": from.UTC(), + "to": to.UTC(), + "bucket_seconds": bucket, + "buckets": out, + }) +} + +// auditBreakdownHandler groups events by one dimension and reports +// count + errors + avg latency per group. Dimensions: endpoint_group, +// route_name, status, auth_type, user. Limited to the top N groups +// (default 20). +func (p *PortalAPI) auditBreakdown(w http.ResponseWriter, r *http.Request) { + dimension := r.URL.Query().Get("dimension") + if dimension == "" { + dimension = "endpoint_group" + } + keyFn, ok := breakdownKeyFn(dimension) + if !ok { + writeError(w, http.StatusBadRequest, fmt.Errorf("unknown dimension %q; valid: endpoint_group, route_name, status, auth_type, user", dimension)) + return + } + // `top` caps the number of groups returned, not the number of + // events scanned. Distinct from `?limit=` (events-list pagination) + // so the two semantics don't collide. + top := 20 + if v, _ := strconv.Atoi(r.URL.Query().Get("top")); v > 0 && v <= 200 { + top = v + } + + f, from, to := timeWindow(r) + events, err := p.audit.Query(r.Context(), f) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + type aggregate struct { + Count int + Errors int + DurationMS int64 + } + grouped := map[string]*aggregate{} + for _, ev := range events { + k := keyFn(ev) + agg := grouped[k] + if agg == nil { + agg = &aggregate{} + grouped[k] = agg + } + agg.Count++ + agg.DurationMS += ev.DurationMS + if !ev.Success { + agg.Errors++ + } + } + + type groupOut struct { + Key string `json:"key"` + Count int `json:"count"` + Errors int `json:"errors"` + AvgDurationMS float64 `json:"avg_duration_ms"` + } + out := make([]groupOut, 0, len(grouped)) + for k, agg := range grouped { + var avg float64 + if agg.Count > 0 { + avg = float64(agg.DurationMS) / float64(agg.Count) + } + out = append(out, groupOut{ + Key: k, + Count: agg.Count, + Errors: agg.Errors, + AvgDurationMS: round1(avg), + }) + } + // Sort by count desc, then key asc for stability. + sort.Slice(out, func(i, j int) bool { + if out[i].Count != out[j].Count { + return out[i].Count > out[j].Count + } + return out[i].Key < out[j].Key + }) + if len(out) > top { + out = out[:top] + } + writeJSON(w, http.StatusOK, map[string]any{ + "from": from.UTC(), + "to": to.UTC(), + "dimension": dimension, + "groups": out, + }) +} + +// auditStatsHandler returns totals plus p50/p95 latency for the time +// window. +func (p *PortalAPI) auditStats(w http.ResponseWriter, r *http.Request) { + f, from, to := timeWindow(r) + + events, err := p.audit.Query(r.Context(), f) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + durations := make([]int64, 0, len(events)) + total := 0 + errs := 0 + for _, ev := range events { + total++ + if !ev.Success { + errs++ + } + durations = append(durations, ev.DurationMS) + } + sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] }) + + var errorRate float64 + if total > 0 { + errorRate = float64(errs) / float64(total) + } + writeJSON(w, http.StatusOK, map[string]any{ + "from": from.UTC(), + "to": to.UTC(), + "total": total, + "errors": errs, + "success": total - errs, + "error_rate": round3(errorRate), + "p50_ms": percentile(durations, 0.50), + "p95_ms": percentile(durations, 0.95), + "p99_ms": percentile(durations, 0.99), + }) +} + +// timeWindow returns the parsed filter plus the resolved window. +// Defaults: to = now, from = 1h ago. +// +// The Logger's Query honors [From, To] inclusive on both ends; the +// in-handler filters in auditTimeseries use a half-open [from, to) +// semantic for bucket assignment so an event at exactly `to` doesn't +// inflate a +1 boundary slot. The two differ only on the single +// boundary timestamp, which is measure-zero in real traffic. +// +// Limit/Offset from parseQueryFilter are intentionally overridden: +// aggregation endpoints have no per-page pagination, so the events- +// list ?limit=/?offset= would otherwise silently truncate the event +// set and yield wrong totals/percentiles. +func timeWindow(r *http.Request) (audit.QueryFilter, time.Time, time.Time) { + f := parseQueryFilter(r) + now := time.Now().UTC() + to := f.To + if to.IsZero() { + to = now + } + from := f.From + if from.IsZero() { + from = to.Add(-1 * time.Hour) + } + if from.After(to) { + from, to = to, from + } + f.From = from + f.To = to + f.Limit = audit.MaxQueryLimit + f.Offset = 0 + return f, from, to +} + +// bucketSeconds extracts ?bucket_seconds= clamped to [1, 3600]; default 60. +func bucketSeconds(r *http.Request) int { + v, _ := strconv.Atoi(r.URL.Query().Get("bucket_seconds")) + if v <= 0 { + return 60 + } + if v > 3600 { + return 3600 + } + return v +} + +// breakdownKeyFn returns the per-event key function for the named +// dimension. The boolean reports validity. +func breakdownKeyFn(dimension string) (func(audit.Event) string, bool) { + switch dimension { + case "endpoint_group": + return func(ev audit.Event) string { + if ev.EndpointGroup == "" { + return "(unknown)" + } + return ev.EndpointGroup + }, true + case "route_name": + return func(ev audit.Event) string { + if ev.RouteName == "" { + return "(unknown)" + } + return ev.RouteName + }, true + case "status": + return func(ev audit.Event) string { return strconv.Itoa(ev.Status) }, true + case "auth_type": + return func(ev audit.Event) string { + if ev.AuthType == "" { + return "(unauthenticated)" + } + return ev.AuthType + }, true + case "user": + return func(ev audit.Event) string { + if ev.UserEmail != "" { + return ev.UserEmail + } + if ev.UserSubject != "" { + return ev.UserSubject + } + if ev.APIKeyName != "" { + return "key:" + ev.APIKeyName + } + return "(anonymous)" + }, true + } + return nil, false +} + +// percentile returns the value at the requested percentile of a sorted +// slice. Returns 0 for an empty slice. Uses nearest-rank (no +// interpolation) — simple and adequate for the dashboard. +func percentile(sorted []int64, p float64) int64 { + if len(sorted) == 0 { + return 0 + } + idx := int(float64(len(sorted)-1) * p) + if idx < 0 { + idx = 0 + } + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} + +func round1(v float64) float64 { + return float64(int(v*10+0.5)) / 10 +} + +func round3(v float64) float64 { + return float64(int(v*1000+0.5)) / 1000 +} diff --git a/pkg/httpsrv/portal_audit_aggregations_test.go b/pkg/httpsrv/portal_audit_aggregations_test.go new file mode 100644 index 0000000..8852f0b --- /dev/null +++ b/pkg/httpsrv/portal_audit_aggregations_test.go @@ -0,0 +1,364 @@ +package httpsrv + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/plexara/api-test/pkg/audit" +) + +// seedEvents adds n synthetic events spanning [start, start+window) with +// rotating success/failure and ascending duration_ms. Used by every test +// in this file. +func seedEvents(t *testing.T, log *audit.MemoryLogger, start time.Time, window time.Duration, n int) { + t.Helper() + for i := 0; i < n; i++ { + ts := start.Add(time.Duration(i) * window / time.Duration(n)) + ev := audit.Event{ + ID: "ev-" + itoa3(i), + Timestamp: ts, + DurationMS: int64(10 + i*5), + Method: []string{"GET", "POST"}[i%2], + Path: []string{"/v1/whoami", "/v1/echo"}[i%2], + RouteName: []string{"whoami", "echo_post"}[i%2], + EndpointGroup: []string{"identity", "echo"}[i%2], + Status: []int{200, 200, 200, 500}[i%4], + Success: i%4 != 3, + AuthType: []string{"apikey", "bearer"}[i%2], + UserEmail: []string{"a@x", "b@x"}[i%2], + } + if err := log.Log(context.Background(), ev); err != nil { + t.Fatalf("seed event %d: %v", i, err) + } + } +} + +func TestAuditTimeseries_Shape(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + start := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Minute) + seedEvents(t, log, start, 30*time.Minute, 60) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/timeseries?from="+start.Format(time.RFC3339)+ + "&to="+start.Add(30*time.Minute).Format(time.RFC3339)+ + "&bucket_seconds=300", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d (body=%s)", w.Code, w.Body.String()) + } + var body struct { + BucketSeconds int `json:"bucket_seconds"` + Buckets []struct { + Time time.Time `json:"time"` + Count int `json:"count"` + Errors int `json:"errors"` + AvgDurationMS float64 `json:"avg_duration_ms"` + } `json:"buckets"` + } + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, w.Body.String()) + } + if body.BucketSeconds != 300 { + t.Errorf("bucket_seconds = %d, want 300", body.BucketSeconds) + } + // 30-minute window at 5-minute buckets = 6 buckets (plus the + // boundary slot from the +1 in totalBuckets). + if len(body.Buckets) != 7 { + t.Errorf("got %d buckets, want 7 (30m/5m + boundary)", len(body.Buckets)) + } + total := 0 + for _, b := range body.Buckets { + total += b.Count + } + if total != 60 { + t.Errorf("sum of bucket counts = %d, want 60", total) + } +} + +func TestAuditTimeseries_BucketTooSmall(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + // 24h window with bucket=1s → 86400 buckets, over the 10000 cap. + from := time.Now().UTC().Add(-24 * time.Hour).Format(time.RFC3339) + to := time.Now().UTC().Format(time.RFC3339) + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/timeseries?from="+from+"&to="+to+"&bucket_seconds=1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400", w.Code) + } +} + +func TestAuditBreakdown_ByEndpointGroup(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + // Start slightly inside the default 1h window so timeWindow's + // later time.Now() call doesn't push the first event out. + start := time.Now().UTC().Add(-50 * time.Minute) + seedEvents(t, log, start, 40*time.Minute, 40) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/breakdown?dimension=endpoint_group", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d (body=%s)", w.Code, w.Body.String()) + } + var body struct { + Dimension string `json:"dimension"` + Groups []struct { + Key string `json:"key"` + Count int `json:"count"` + Errors int `json:"errors"` + } `json:"groups"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + if body.Dimension != "endpoint_group" { + t.Errorf("dimension = %q", body.Dimension) + } + keys := map[string]int{} + for _, g := range body.Groups { + keys[g.Key] = g.Count + } + if keys["identity"] != 20 || keys["echo"] != 20 { + t.Errorf("group counts wrong: %v", keys) + } +} + +func TestAuditBreakdown_UnknownDimension(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/breakdown?dimension=nonsense", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400", w.Code) + } +} + +func TestAuditBreakdown_ByStatus(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + start := time.Now().UTC().Add(-50 * time.Minute) + seedEvents(t, log, start, 40*time.Minute, 40) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/breakdown?dimension=status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var body struct { + Groups []struct { + Key string `json:"key"` + Count int `json:"count"` + Errors int `json:"errors"` + } `json:"groups"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + got := map[string]int{} + for _, g := range body.Groups { + got[g.Key] = g.Count + } + // Seed rotates status across [200, 200, 200, 500] → 30 successes + // at 200, 10 errors at 500. + if got["200"] != 30 || got["500"] != 10 { + t.Errorf("status breakdown wrong: %v", got) + } +} + +func TestAuditStats_Percentiles(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + start := time.Now().UTC().Add(-50 * time.Minute) + seedEvents(t, log, start, 40*time.Minute, 20) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/stats", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var body struct { + Total int `json:"total"` + Errors int `json:"errors"` + Success int `json:"success"` + ErrorRate float64 `json:"error_rate"` + P50MS int64 `json:"p50_ms"` + P95MS int64 `json:"p95_ms"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + if body.Total != 20 { + t.Errorf("total = %d, want 20", body.Total) + } + if body.Errors+body.Success != body.Total { + t.Errorf("success(%d)+errors(%d) != total(%d)", body.Success, body.Errors, body.Total) + } + // p50 should be lower than p95 with non-degenerate data. + if body.P50MS >= body.P95MS { + t.Errorf("p50 (%d) should be less than p95 (%d)", body.P50MS, body.P95MS) + } +} + +// TestAggregationsIgnoreLimitQueryParam guards against a regression +// where parseQueryFilter forwarded ?limit= and ?offset= into the +// underlying audit.Logger.Query, silently truncating the event set +// behind the aggregations and producing wrong totals/percentiles. +func TestAggregationsIgnoreLimitQueryParam(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + start := time.Now().UTC().Add(-50 * time.Minute) + seedEvents(t, log, start, 40*time.Minute, 40) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + t.Run("stats", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/stats?limit=5", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + var body struct { + Total int `json:"total"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + if body.Total != 40 { + t.Errorf("total = %d, want 40 (limit param should not truncate event scan)", body.Total) + } + }) + + t.Run("breakdown", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/breakdown?dimension=endpoint_group&limit=5", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + var body struct { + Groups []struct { + Key string `json:"key"` + Count int `json:"count"` + } `json:"groups"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + total := 0 + for _, g := range body.Groups { + total += g.Count + } + if total != 40 { + t.Errorf("group counts sum to %d, want 40 (limit param should not truncate event scan)", total) + } + }) + + t.Run("breakdown_top_caps_groups_not_events", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/breakdown?dimension=status&top=1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + var body struct { + Groups []struct { + Key string `json:"key"` + Count int `json:"count"` + } `json:"groups"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + if len(body.Groups) != 1 { + t.Errorf("got %d groups, want 1 (top=1 caps output groups)", len(body.Groups)) + } + // The one group returned should reflect counting all 40 events. + // status=200 dominates (30 events). + if body.Groups[0].Count != 30 { + t.Errorf("top group count = %d, want 30 (top caps output, not scan)", body.Groups[0].Count) + } + }) + + t.Run("timeseries", func(t *testing.T) { + // Use bucket_seconds=300 (5m) over the seed window so we get + // a small, predictable response shape. + from := start.Format(time.RFC3339) + to := start.Add(40 * time.Minute).Format(time.RFC3339) + req := httptest.NewRequest(http.MethodGet, + "/api/v1/portal/audit/timeseries?bucket_seconds=300&limit=5&from="+from+"&to="+to, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + var body struct { + Buckets []struct { + Count int `json:"count"` + } `json:"buckets"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + total := 0 + for _, b := range body.Buckets { + total += b.Count + } + if total != 40 { + t.Errorf("bucket counts sum to %d, want 40 (limit param should not truncate)", total) + } + }) +} + +func TestPercentileEmpty(t *testing.T) { + if got := percentile(nil, 0.5); got != 0 { + t.Errorf("percentile(nil) = %d, want 0", got) + } +} + +func TestPercentile_NearestRank(t *testing.T) { + sorted := []int64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100} + cases := []struct { + p float64 + want int64 + }{ + {0.00, 10}, + {0.50, 50}, + {0.95, 90}, + {0.99, 90}, // (10-1)*0.99 = 8.91 → idx 8 → sorted[8] = 90 + {1.00, 100}, + } + for _, c := range cases { + if got := percentile(sorted, c.p); got != c.want { + t.Errorf("percentile(%.2f) = %d, want %d", c.p, got, c.want) + } + } +} + +func TestAuditMeta_AdvertisesEnabledFeatures(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/meta", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var body struct { + Features map[string]bool `json:"features"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + for _, k := range []string{"timeseries", "breakdown", "stats"} { + if !body.Features[k] { + t.Errorf("feature %s should be true (just shipped)", k) + } + } +} + +func itoa3(n int) string { + const digits = "0123456789" + return string([]byte{ + digits[(n/100)%10], + digits[(n/10)%10], + digits[n%10], + }) +} diff --git a/pkg/httpsrv/portal_audit_replay.go b/pkg/httpsrv/portal_audit_replay.go new file mode 100644 index 0000000..522ddf3 --- /dev/null +++ b/pkg/httpsrv/portal_audit_replay.go @@ -0,0 +1,200 @@ +package httpsrv + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" + + "github.com/plexara/api-test/pkg/audit" +) + +// replayHeaderMarker is the header attached to every replayed request +// so a replay can't infinite-loop into itself. The audit middleware +// also reads this header to populate Payload.ReplayedFrom on the new +// event row, so the constant lives in pkg/audit (audit.ReplayHeaderName) +// — this name is a local alias to keep the call sites short. +const replayHeaderMarker = audit.ReplayHeaderName + +// replayMaxBodyBytes caps the request body we'll re-send and the +// response body we'll capture so a hostile captured payload can't +// allocate large amounts of memory at replay time. +const replayMaxBodyBytes = 1 << 20 // 1 MiB + +// auditReplay re-issues a captured request through the local mux. +// Requires the audit Logger to implement PayloadLogger (so we can +// reconstruct headers + body) and the composition layer to have wired +// a replayTarget via WithReplayTarget. Refuses to replay non-/v1/* +// paths and refuses to replay anything that already carries the loop +// marker. +// +// Returns: +// +// { +// "replayed_from": "", +// "status": 200, +// "headers": {...}, +// "body": "", +// "body_truncated": false +// } +func (p *PortalAPI) auditReplay(w http.ResponseWriter, r *http.Request) { + if p.replayTarget == nil { + writeError(w, http.StatusServiceUnavailable, + errors.New("replay disabled: composition did not supply a target handler")) + return + } + + rawID := r.PathValue("id") + parsed, err := uuid.Parse(rawID) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("event id is not a valid uuid")) + return + } + id := parsed.String() + + events, err := p.audit.Query(r.Context(), audit.QueryFilter{Limit: 1, EventID: id}) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if len(events) == 0 { + writeError(w, http.StatusNotFound, fmt.Errorf("event %q not found", id)) + return + } + ev := events[0] + + if !strings.HasPrefix(ev.Path, "/v1/") { + writeError(w, http.StatusBadRequest, + fmt.Errorf("path %q is not replay-eligible (only /v1/* routes can be replayed)", ev.Path)) + return + } + + pl, ok := p.audit.(audit.PayloadLogger) + if !ok { + writeError(w, http.StatusServiceUnavailable, + errors.New("audit logger does not persist payloads — replay requires PayloadLogger")) + return + } + payload, perr := pl.GetPayload(r.Context(), id) + if perr != nil { + writeError(w, http.StatusInternalServerError, + fmt.Errorf("fetch payload for event %q: %w", id, perr)) + return + } + if payload == nil { + writeError(w, http.StatusNotFound, + fmt.Errorf("payload for event %q not captured (audit.capture_payloads must be enabled when the event was recorded)", id)) + return + } + + // Build the replay URL: keep the captured path; restore the + // captured query parameters. Path comes from the audit log of a + // previously-routed request, so url.Parse can't fail — but we + // surface the (impossible) error anyway. + u, err := url.Parse(ev.Path) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("parse captured path: %w", err)) + return + } + if len(payload.RequestQuery) > 0 { + values := url.Values{} + for k, vs := range payload.RequestQuery { + for _, v := range vs { + values.Add(k, v) + } + } + u.RawQuery = values.Encode() + } + + body := payload.RequestBody + if len(body) > replayMaxBodyBytes { + body = body[:replayMaxBodyBytes] + } + replayReq, err := http.NewRequestWithContext(r.Context(), + ev.Method, u.String(), bytes.NewReader(body)) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("construct replay request: %w", err)) + return + } + + // Copy captured headers but refuse to replay anything already + // carrying our marker (loop guard). + for k, vs := range payload.RequestHeaders { + if strings.EqualFold(k, replayHeaderMarker) { + writeError(w, http.StatusBadRequest, + errors.New("captured request already carries the replay marker — refusing to replay a replay")) + return + } + for _, v := range vs { + replayReq.Header.Add(k, v) + } + } + replayReq.Header.Set(replayHeaderMarker, id) + if payload.RequestContentType != "" && replayReq.Header.Get("Content-Type") == "" { + replayReq.Header.Set("Content-Type", payload.RequestContentType) + } + + rec := newCapResponseWriter(replayMaxBodyBytes) + p.replayTarget.ServeHTTP(rec, replayReq) + + writeJSON(w, http.StatusOK, map[string]any{ + "replayed_from": id, + "status": rec.status, + "headers": rec.headers, + "body": rec.body.String(), + "body_truncated": rec.truncated, + }) +} + +// capResponseWriter buffers up to maxBytes of response body, then +// silently drops further writes. Avoids the unbounded buffering of +// httptest.NewRecorder when a replayed endpoint emits a multi-MiB +// body (e.g. /v1/sized?bytes=33554432 → 32 MiB). +type capResponseWriter struct { + headers http.Header + status int + body bytes.Buffer + maxBytes int + truncated bool + wroteHdr bool +} + +func newCapResponseWriter(maxBytes int) *capResponseWriter { + return &capResponseWriter{ + headers: http.Header{}, + status: http.StatusOK, + maxBytes: maxBytes, + } +} + +func (c *capResponseWriter) Header() http.Header { return c.headers } + +func (c *capResponseWriter) WriteHeader(status int) { + if c.wroteHdr { + return + } + c.status = status + c.wroteHdr = true +} + +func (c *capResponseWriter) Write(p []byte) (int, error) { + if !c.wroteHdr { + c.WriteHeader(http.StatusOK) + } + remaining := c.maxBytes - c.body.Len() + if remaining <= 0 { + c.truncated = true + return len(p), nil + } + if len(p) > remaining { + c.body.Write(p[:remaining]) + c.truncated = true + return len(p), nil + } + c.body.Write(p) + return len(p), nil +} diff --git a/pkg/httpsrv/portal_audit_replay_test.go b/pkg/httpsrv/portal_audit_replay_test.go new file mode 100644 index 0000000..c990e57 --- /dev/null +++ b/pkg/httpsrv/portal_audit_replay_test.go @@ -0,0 +1,419 @@ +package httpsrv + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/plexara/api-test/pkg/audit" +) + +// payloadLoggingMemory wraps audit.MemoryLogger and adds a per-ID +// payload store so the replay handler can fetch RequestHeaders / +// RequestBody / RequestQuery for events. MemoryLogger itself doesn't +// persist payloads; this stand-in keeps the test self-contained. +type payloadLoggingMemory struct { + *audit.MemoryLogger + payloads map[string]*audit.Payload +} + +func newPayloadLoggingMemory() *payloadLoggingMemory { + return &payloadLoggingMemory{ + MemoryLogger: audit.NewMemoryLogger(), + payloads: map[string]*audit.Payload{}, + } +} + +func (m *payloadLoggingMemory) GetPayload(_ context.Context, id string) (*audit.Payload, error) { + if p, ok := m.payloads[id]; ok { + return p, nil + } + return nil, nil +} + +func (m *payloadLoggingMemory) seedEventWithPayload(t *testing.T, ev audit.Event, payload *audit.Payload) { + t.Helper() + if err := m.MemoryLogger.Log(context.Background(), ev); err != nil { + t.Fatalf("seed: %v", err) + } + m.payloads[ev.ID] = payload +} + +// stubV1Mux returns a *http.ServeMux registered with one /v1/* route +// that echoes its method+path+body so a replay can be observed. +func stubV1Mux(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("POST /v1/echo", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = r.Body.Close() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{ + "method": r.Method, + "path": r.URL.Path, + "body": string(body), + "replay": r.Header.Get(replayHeaderMarker), + }) + }) + return mux +} + +func TestAuditReplay_DispatchesThroughTarget(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + // Inject a tiny dispatch target with one /v1/echo route. + target := stubV1Mux(t) + p.WithReplayTarget(target) + + id := uuid.NewString() + log.seedEventWithPayload(t, + audit.Event{ + ID: id, + Timestamp: time.Now().UTC().Add(-1 * time.Minute), + Method: http.MethodPost, + Path: "/v1/echo", + Status: 200, + Success: true, + }, + &audit.Payload{ + RequestHeaders: map[string][]string{"X-Custom": {"value"}}, + RequestBody: []byte(`{"hi":1}`), + RequestContentType: "application/json", + }, + ) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/"+id, nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") // bypass requireCSRFHeader + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d (body=%s)", w.Code, w.Body.String()) + } + + var resp struct { + ReplayedFrom string `json:"replayed_from"` + Status int `json:"status"` + Body string `json:"body"` + Headers http.Header `json:"headers"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v (body=%s)", err, w.Body.String()) + } + if resp.ReplayedFrom != id { + t.Errorf("replayed_from = %q, want %q", resp.ReplayedFrom, id) + } + if resp.Status != 200 { + t.Errorf("dispatched status = %d, want 200", resp.Status) + } + + // Echo body should reflect the captured method/path/body AND the + // replay marker header. + var echoed map[string]string + if err := json.Unmarshal([]byte(resp.Body), &echoed); err != nil { + t.Fatalf("echoed body not JSON: %v (body=%q)", err, resp.Body) + } + if echoed["method"] != "POST" || echoed["path"] != "/v1/echo" || + echoed["body"] != `{"hi":1}` { + t.Errorf("echoed shape wrong: %+v", echoed) + } + if echoed["replay"] != id { + t.Errorf("X-Plexara-Replay-From header = %q, want %q", echoed["replay"], id) + } +} + +func TestAuditReplay_DisabledWhenNoTarget(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + // Do NOT call WithReplayTarget; do NOT Mount onto a real mux. + // We mount onto an empty mux to exercise the "no target" branch. + + mux := http.NewServeMux() + // Skip the Mount-side auto-wiring by replicating the route registration: + mux.Handle("POST /api/v1/portal/audit/replay/{id}", + passthroughMW(requireCSRFHeader(http.HandlerFunc(p.auditReplay)))) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/"+uuid.NewString(), nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status %d, want 503 (replay disabled without target)", w.Code) + } +} + +func TestAuditReplay_RejectsInvalidUUID(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + p.WithReplayTarget(stubV1Mux(t)) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/not-a-uuid", nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400", w.Code) + } +} + +func TestAuditReplay_RejectsNonV1Path(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + p.WithReplayTarget(stubV1Mux(t)) + + id := uuid.NewString() + log.seedEventWithPayload(t, + audit.Event{ID: id, Method: "POST", Path: "/api/v1/portal/audit/events", Status: 200}, + &audit.Payload{}, + ) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/"+id, nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400 (non-/v1 path must be refused)", w.Code) + } + if !strings.Contains(w.Body.String(), "not replay-eligible") { + t.Errorf("error body should mention 'not replay-eligible': %s", w.Body.String()) + } +} + +func TestAuditReplay_RefusesReplayOfReplay(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + p.WithReplayTarget(stubV1Mux(t)) + + id := uuid.NewString() + log.seedEventWithPayload(t, + audit.Event{ID: id, Method: "POST", Path: "/v1/echo", Status: 200}, + &audit.Payload{ + RequestHeaders: map[string][]string{ + replayHeaderMarker: {"prior-replay-id"}, + }, + }, + ) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/"+id, nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400 (replay-of-replay must be refused)", w.Code) + } +} + +func TestAuditReplay_PayloadMissing(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + p.WithReplayTarget(stubV1Mux(t)) + + id := uuid.NewString() + // Log the event but skip the payload sibling. + _ = log.MemoryLogger.Log(context.Background(), audit.Event{ + ID: id, + Method: "POST", + Path: "/v1/echo", + }) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/"+id, nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("status %d, want 404 (no payload captured)", w.Code) + } +} + +func TestAuditReplay_PreservesQueryParameters(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + + target := http.NewServeMux() + target.HandleFunc("GET /v1/query-echo", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "query": r.URL.Query(), + }) + }) + p.WithReplayTarget(target) + + id := uuid.NewString() + log.seedEventWithPayload(t, + audit.Event{ID: id, Method: "GET", Path: "/v1/query-echo"}, + &audit.Payload{ + RequestQuery: map[string][]string{ + "a": {"1", "2"}, + "b": {"x"}, + }, + }, + ) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/audit/replay/"+id, nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d (body=%s)", w.Code, w.Body.String()) + } + var outer struct { + Body string `json:"body"` + } + _ = json.Unmarshal(w.Body.Bytes(), &outer) + var inner struct { + Query map[string][]string `json:"query"` + } + _ = json.Unmarshal([]byte(outer.Body), &inner) + if got := inner.Query["a"]; len(got) != 2 || got[0] != "1" || got[1] != "2" { + t.Errorf("query a = %v, want [1 2]", got) + } + if got := inner.Query["b"]; len(got) != 1 || got[0] != "x" { + t.Errorf("query b = %v, want [x]", got) + } +} + +// TestAuditReplay_CapsResponseBodyWithoutBuffering regresses the +// unbounded-buffering bug. Stub target emits ~5 MiB; the replay +// must cap at replayMaxBodyBytes (1 MiB). +func TestAuditReplay_CapsResponseBodyWithoutBuffering(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + + target := http.NewServeMux() + target.HandleFunc("GET /v1/large", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + chunk := bytes.Repeat([]byte("A"), 32*1024) + for i := 0; i < 160; i++ { + _, _ = w.Write(chunk) + } + }) + p.WithReplayTarget(target) + + id := uuid.NewString() + log.seedEventWithPayload(t, + audit.Event{ID: id, Method: "GET", Path: "/v1/large"}, + &audit.Payload{}, + ) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodPost, "/api/v1/portal/audit/replay/"+id, nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var resp struct { + Body string `json:"body"` + BodyTruncated bool `json:"body_truncated"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if !resp.BodyTruncated { + t.Errorf("body_truncated = false; expected true for >1 MiB response") + } + if len(resp.Body) > replayMaxBodyBytes { + t.Errorf("captured body is %d bytes; cap is %d", len(resp.Body), replayMaxBodyBytes) + } + if len(resp.Body) < replayMaxBodyBytes/2 { + t.Errorf("captured body suspiciously small: %d bytes", len(resp.Body)) + } +} + +// TestAuditReplay_SetsReplayMarkerOnDispatchedRequest regresses the +// missing-lineage bug. The replay handler attaches the marker header +// to the dispatched request; the audit middleware uses it to populate +// Payload.ReplayedFrom on the new audit row. +func TestAuditReplay_SetsReplayMarkerOnDispatchedRequest(t *testing.T) { + log := newPayloadLoggingMemory() + p := NewPortalAPI(nil, nil, log, nil) + + var observedMarker string + target := http.NewServeMux() + target.HandleFunc("GET /v1/check", func(_ http.ResponseWriter, r *http.Request) { + observedMarker = r.Header.Get(audit.ReplayHeaderName) + }) + p.WithReplayTarget(target) + + id := uuid.NewString() + log.seedEventWithPayload(t, + audit.Event{ID: id, Method: "GET", Path: "/v1/check"}, + &audit.Payload{}, + ) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodPost, "/api/v1/portal/audit/replay/"+id, nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if observedMarker != id { + t.Errorf("dispatched request did not carry %s = %q (got %q)", + audit.ReplayHeaderName, id, observedMarker) + } +} + +// noPayloadLogger implements audit.Logger but NOT audit.PayloadLogger, +// so the replay-feature-flag gate can be exercised. Production's +// NoopLogger doesn't implement PayloadLogger either; this stub mirrors +// that shape for the test. +type noPayloadLogger struct{} + +func (noPayloadLogger) Log(context.Context, audit.Event) error { return nil } +func (noPayloadLogger) Query(context.Context, audit.QueryFilter) ([]audit.Event, error) { + return nil, nil +} +func (noPayloadLogger) Count(context.Context, audit.QueryFilter) (int64, error) { return 0, nil } + +// TestAuditReplay_FeatureFlagFalseWithoutPayloadLogger regresses the +// misleading-flag bug: features.replay must be false when the Logger +// can't actually serve payloads. +func TestAuditReplay_FeatureFlagFalseWithoutPayloadLogger(t *testing.T) { + p := NewPortalAPI(nil, nil, noPayloadLogger{}, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + w := httptest.NewRecorder() + mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/meta", nil)) + var body struct { + Features map[string]bool `json:"features"` + } + _ = json.Unmarshal(w.Body.Bytes(), &body) + if body.Features["replay"] { + t.Errorf("features.replay should be false when audit Logger is not a PayloadLogger") + } +} diff --git a/pkg/httpsrv/portal_audit_stream.go b/pkg/httpsrv/portal_audit_stream.go new file mode 100644 index 0000000..5478104 --- /dev/null +++ b/pkg/httpsrv/portal_audit_stream.go @@ -0,0 +1,225 @@ +package httpsrv + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/plexara/api-test/pkg/audit" +) + +const ( + // auditStreamPollInterval is how often the SSE handler polls the + // audit Logger for new events. 1s is fast enough for a dashboard + // live-tail and slow enough to avoid hammering the DB. + auditStreamPollInterval = 1 * time.Second + + // auditStreamHeartbeatInterval is how often a comment line is + // flushed when no events have arrived. Keeps intermediaries from + // closing the connection as idle. + auditStreamHeartbeatInterval = 15 * time.Second + + // auditStreamMaxPagesPerTick bounds the inner page loop so a + // pathological backlog can't pin the handler indefinitely. + // Combined with audit.MaxQueryLimit this caps each tick at + // 10*1000 = 10k events, which is more than the dashboard could + // usefully render even if every one of them arrived in a single + // second. + auditStreamMaxPagesPerTick = 10 + + // auditExportLimit caps NDJSON export at 100k events. The audit + // Logger's MaxQueryLimit is 1000 per call; we paginate via + // repeated Query() calls and stop here as defense against runaway. + auditExportLimit = 100_000 +) + +// auditStream serves a long-lived Server-Sent Events stream of new +// audit_events. Polls every auditStreamPollInterval; emits a heartbeat +// comment every auditStreamHeartbeatInterval if the queue is empty. +// Honors r.Context() cancellation so a closed connection ends the loop +// promptly. +func (p *PortalAPI) auditStream(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + writeError(w, http.StatusInternalServerError, fmt.Errorf("streaming not supported by this writer")) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + + // Establish the high-water mark at connection time so we don't + // flood the client with historical events. The client gets the + // existing /audit/events endpoint for historical replay. + lastSeen := time.Now().UTC() + + pollTicker := time.NewTicker(auditStreamPollInterval) + defer pollTicker.Stop() + heartbeatTicker := time.NewTicker(auditStreamHeartbeatInterval) + defer heartbeatTicker.Stop() + + // Send an initial comment so the EventSource onopen handler fires. + _, _ = fmt.Fprintln(w, ": stream open") + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + + case <-heartbeatTicker.C: + _, _ = fmt.Fprintln(w, ": heartbeat") + flusher.Flush() + + case <-pollTicker.C: + now := time.Now().UTC() + // Page within the tick: Query orders DESC and clamps at + // MaxQueryLimit. A burst exceeding the clamp would + // silently drop the older half if we advanced lastSeen + // directly to `now` without paging. Walk the upper bound + // down to the oldest emitted event's timestamp; dedup + // the boundary by event ID since Logger boundaries are + // inclusive on both ends. + cursor := now + seen := map[string]bool{} + emittedAny := false + for page := 0; page < auditStreamMaxPagesPerTick; page++ { + events, err := p.audit.Query(r.Context(), audit.QueryFilter{ + From: lastSeen, + To: cursor, + Limit: audit.MaxQueryLimit, + }) + if err != nil { + _, _ = fmt.Fprintf(w, "event: error\ndata: %s\n\n", jsonEscape(err.Error())) + flusher.Flush() + break + } + freshThisPage := 0 + for i := range events { + ev := events[i] + // Skip events at exactly lastSeen to avoid + // duplicating the boundary on the next tick. + if !ev.Timestamp.After(lastSeen) { + continue + } + if seen[ev.ID] { + continue + } + seen[ev.ID] = true + payload, _ := json.Marshal(ev) + _, _ = fmt.Fprintf(w, "id: %s\nevent: audit\ndata: %s\n\n", + ev.ID, payload) + emittedAny = true + freshThisPage++ + } + if len(events) < audit.MaxQueryLimit { + break + } + if freshThisPage == 0 { + // Boundary stuck — every event on this page + // was already in `seen` (a burst of events + // at the same timestamp; the cursor can't + // advance past a tied-ts page without an + // ID-tiebreaker filter on the Logger). Emit + // an explicit saturated frame so the SPA can + // surface that events were dropped rather + // than silently losing them. + _, _ = fmt.Fprintf(w, "event: saturated\ndata: {\"reason\":\"tied_timestamps\",\"emitted_this_tick\":%d}\n\n", + len(seen)) + break + } + // DESC order: oldest is at len-1; advance the + // upper bound (inclusive) to it. The seen map + // suppresses re-emission on the boundary. + cursor = events[len(events)-1].Timestamp + } + if emittedAny { + flusher.Flush() + } + lastSeen = now + } + } +} + +// auditExportNDJSON streams matching events as newline-delimited JSON. +// Paginates through the Logger via repeated Query() calls because the +// per-call MaxQueryLimit is small. Stops at auditExportLimit as a +// safety cap. +// +// Concurrent inserts: the handler pins the upper bound on the time +// window at entry. Events with ts > entry-time are filtered out of +// every page, so offset pagination stays stable even when new audit +// events arrive during the export. Without this pin, a row inserted +// between pages would shift every subsequent row one position later +// in the DESC ordering, duplicating the boundary row of each page. +func (p *PortalAPI) auditExportNDJSON(w http.ResponseWriter, r *http.Request) { + f := parseQueryFilter(r) + // Force MaxQueryLimit per page so we don't accidentally honor a + // caller-supplied small ?limit= as a per-page hint. + f.Limit = audit.MaxQueryLimit + f.Offset = 0 + // Pin the upper bound to NOW so concurrent inserts don't shift + // the offset window mid-export. + if f.To.IsZero() || f.To.After(time.Now().UTC()) { + f.To = time.Now().UTC() + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Content-Disposition", `attachment; filename="audit-events.ndjson"`) + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + written := 0 + enc := json.NewEncoder(w) + for written < auditExportLimit { + if r.Context().Err() != nil { + return + } + events, err := p.audit.Query(r.Context(), f) + if err != nil { + // Mid-stream error: write a final JSON object documenting + // the error so consumers can spot the partial export. + _ = enc.Encode(map[string]any{"_export_error": err.Error()}) + return + } + if len(events) == 0 { + return + } + for _, ev := range events { + _ = enc.Encode(ev) + written++ + if written >= auditExportLimit { + _ = enc.Encode(map[string]any{ + "_export_truncated": true, + "_limit": auditExportLimit, + }) + return + } + } + if flusher != nil { + flusher.Flush() + } + // Advance: ask for the next page. The audit Query orders + // newest-first by default; we advance by offset to walk + // the whole window. + f.Offset += len(events) + // If a backend returns fewer than the requested limit, + // we've reached the end. + if len(events) < f.Limit { + return + } + } +} + +// jsonEscape returns a JSON-safe quoted version of s, minus the outer +// quotes, suitable for embedding in an SSE `data:` payload. +func jsonEscape(s string) string { + b, _ := json.Marshal(s) + if len(b) < 2 { + return "" + } + return string(b[1 : len(b)-1]) +} diff --git a/pkg/httpsrv/portal_audit_stream_test.go b/pkg/httpsrv/portal_audit_stream_test.go new file mode 100644 index 0000000..6871abd --- /dev/null +++ b/pkg/httpsrv/portal_audit_stream_test.go @@ -0,0 +1,379 @@ +package httpsrv + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/plexara/api-test/pkg/audit" +) + +func TestAuditExportNDJSON_StreamsAllEvents(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + // Timestamps in the past — that's where real audit events live. + // auditExportNDJSON pins To=now() at entry, so events with ts in + // the future would be filtered out (covered by another test). + start := time.Now().UTC().Add(-1 * time.Hour) + for i := 0; i < 5; i++ { + _ = log.Log(context.Background(), audit.Event{ + ID: "e" + itoa3(i), + Timestamp: start.Add(time.Duration(i) * time.Minute), + Method: "GET", + Path: "/v1/echo", + Status: 200, + Success: true, + }) + } + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/export.ndjson", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "application/x-ndjson" { + t.Errorf("Content-Type = %q", ct) + } + if disp := w.Header().Get("Content-Disposition"); !strings.Contains(disp, "audit-events.ndjson") { + t.Errorf("Content-Disposition = %q", disp) + } + + lines := []string{} + for _, line := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") { + if line != "" { + lines = append(lines, line) + } + } + if len(lines) != 5 { + t.Fatalf("got %d lines, want 5: body=%q", len(lines), w.Body.String()) + } + for i, line := range lines { + var ev audit.Event + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Errorf("line %d not JSON: %v (%q)", i, err, line) + } + } +} + +func TestAuditExportNDJSON_EmptyExport(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/export.ndjson", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("status %d", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("empty export should have empty body, got %q", w.Body.String()) + } +} + +func TestAuditExportNDJSON_IgnoresClientLimit(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + for i := 0; i < 10; i++ { + _ = log.Log(context.Background(), audit.Event{ + ID: "e" + itoa3(i), + Timestamp: time.Now().UTC().Add(-1 * time.Hour).Add(time.Duration(i) * time.Minute), + }) + } + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + req := httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/export.ndjson?limit=3", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + lineCount := 0 + for _, line := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") { + if line != "" { + lineCount++ + } + } + if lineCount != 10 { + t.Errorf("got %d lines, want 10 (?limit= must not cap export)", lineCount) + } +} + +func TestAuditStream_HeadersAndOpens(t *testing.T) { + p, _, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + // Open the stream with a short-lived context so it exits promptly + // after we've observed the open frame. + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + req := httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/stream", nil).WithContext(ctx) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" { + t.Errorf("Content-Type = %q", ct) + } + if !strings.Contains(w.Body.String(), ": stream open") { + t.Errorf("expected ': stream open' in body, got %q", w.Body.String()) + } +} + +func TestAuditStream_DeliversNewEvent(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + // Need a real http server so the SSE flush actually goes through + // the network. httptest.NewRecorder doesn't drive a goroutine. + srv := httptest.NewServer(mux) + defer srv.Close() + + // Open the stream in a goroutine that closes its response body + // when this test ends. + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/v1/portal/audit/stream", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("stream open: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + // Give the handler a tick to record lastSeen, then write an + // event with a timestamp the poll loop will pick up. + time.Sleep(100 * time.Millisecond) + _ = log.Log(context.Background(), audit.Event{ + ID: "stream-test-1", + Timestamp: time.Now().UTC().Add(50 * time.Millisecond), + Method: "GET", + Path: "/v1/whoami", + Status: 200, + Success: true, + }) + + // Read until we see the event frame or hit the context timeout. + deadline := time.Now().Add(3 * time.Second) + buf := make([]byte, 4096) + collected := strings.Builder{} + for time.Now().Before(deadline) { + n, err := resp.Body.Read(buf) + if n > 0 { + collected.WriteString(string(buf[:n])) + if strings.Contains(collected.String(), "event: audit") && + strings.Contains(collected.String(), "stream-test-1") { + return + } + } + if err != nil { + break + } + } + t.Fatalf("did not observe audit event in stream within deadline. got:\n%s", collected.String()) +} + +// itoa3 is defined in portal_audit_aggregations_test.go; reused here. + +// TestAuditExportNDJSON_PinsToNow regresses the offset-pagination +// duplicate-rows bug. With To pinned at handler entry, events whose +// timestamps land AFTER the pin must not appear in the output. +// +// We exercise the pin synchronously: seed past events, then seed a +// future-timestamped event before invoking the handler. The handler's +// `f.To = time.Now()` pin happens at entry, AFTER the future event +// exists in the log; if the pin code is deleted, the future event +// slips through and the test fails. +func TestAuditExportNDJSON_PinsToNow(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + past := time.Now().UTC().Add(-1 * time.Hour) + for i := 0; i < 5; i++ { + _ = log.Log(context.Background(), audit.Event{ + ID: "old" + itoa3(i), + Timestamp: past.Add(time.Duration(i) * time.Minute), + }) + } + // Synchronously add a far-future event BEFORE the handler runs, + // so the pin actually has something to exclude. + _ = log.Log(context.Background(), audit.Event{ + ID: "future-event", + Timestamp: time.Now().UTC().Add(time.Hour), + }) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/portal/audit/export.ndjson", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + body := w.Body.String() + if strings.Contains(body, "future-event") { + t.Errorf("future-ts event must be filtered by pinned To, but appeared in body:\n%s", body) + } + lineCount := 0 + for _, line := range strings.Split(strings.TrimRight(body, "\n"), "\n") { + if line != "" { + lineCount++ + } + } + if lineCount != 5 { + t.Errorf("got %d export lines, want 5 (the future event must be excluded)", lineCount) + } +} + +// TestAuditStream_BurstExceedsBatchSize regresses the silent-event-loss +// bug. Seeding more events than the per-page Query limit in one tick +// must not drop the oldest events. +// +// Memory-backend specific: we drive a tight burst before opening the +// stream, then open it. The handler establishes lastSeen = now() so +// any events with ts < now are excluded — that's the documented +// "open-time baseline" semantics. To exercise within-tick paging we +// instead emit events AFTER the stream opens whose timestamps are +// well-bunched and exceed the MaxQueryLimit by a few. +func TestAuditStream_PagesWithinTickWithoutDroppingEvents(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/v1/portal/audit/stream", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("stream open: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + time.Sleep(100 * time.Millisecond) + + // Inject 1500 events spread over a tiny window. Each ID is + // distinct so we can verify all of them surface in the stream. + const N = 1500 + emitStart := time.Now().UTC().Add(50 * time.Millisecond) + for i := 0; i < N; i++ { + _ = log.Log(context.Background(), audit.Event{ + ID: "burst-" + itoa4(i), + Timestamp: emitStart.Add(time.Duration(i) * time.Microsecond), + Method: "GET", + Path: "/v1/whoami", + }) + } + + // Collect for ~3 seconds (enough for 3 poll ticks) then assert + // every burst ID appears at least once. + deadline := time.Now().Add(3500 * time.Millisecond) + buf := make([]byte, 64*1024) + collected := strings.Builder{} + for time.Now().Before(deadline) { + n, err := resp.Body.Read(buf) + if n > 0 { + collected.WriteString(string(buf[:n])) + } + if err != nil { + break + } + } + body := collected.String() + missing := 0 + for i := 0; i < N; i++ { + if !strings.Contains(body, "burst-"+itoa4(i)) { + missing++ + } + } + if missing > 0 { + t.Errorf("%d/%d burst events missing from stream — within-tick paging dropped them", missing, N) + } +} + +// TestAuditStream_EmitsSaturatedFrameOnTiedTimestamps regresses the +// tied-ts data-loss edge case. When more than MaxQueryLimit events +// share a single timestamp, the cursor in the within-tick page loop +// can't advance past them (no ID tiebreaker filter on the Logger). +// The handler emits an explicit `event: saturated` SSE frame so the +// loss is observable rather than silent. +func TestAuditStream_EmitsSaturatedFrameOnTiedTimestamps(t *testing.T) { + p, log, _ := newTestPortalAPI(t, nil) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + srv := httptest.NewServer(mux) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/api/v1/portal/audit/stream", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("stream open: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + time.Sleep(100 * time.Millisecond) + + // 1100 events all sharing the same timestamp — exceeds the + // per-page MaxQueryLimit (1000), so the cursor stalls. + tied := time.Now().UTC().Add(50 * time.Millisecond) + for i := 0; i < 1100; i++ { + _ = log.Log(context.Background(), audit.Event{ + ID: "tied-" + itoa4(i), + Timestamp: tied, + }) + } + + deadline := time.Now().Add(3 * time.Second) + buf := make([]byte, 64*1024) + collected := strings.Builder{} + for time.Now().Before(deadline) { + n, err := resp.Body.Read(buf) + if n > 0 { + collected.WriteString(string(buf[:n])) + if strings.Contains(collected.String(), "event: saturated") { + return + } + } + if err != nil { + break + } + } + t.Fatalf("did not observe 'event: saturated' frame after 1100 tied-ts events. got:\n%s", + collected.String()) +} + +// itoa4 returns a 4-digit zero-padded decimal string. +func itoa4(n int) string { + const digits = "0123456789" + return string([]byte{ + digits[(n/1000)%10], + digits[(n/100)%10], + digits[(n/10)%10], + digits[n%10], + }) +} + +func TestJSONEscape_HandlesQuotesAndControlBytes(t *testing.T) { + cases := map[string]string{ + `plain`: `plain`, + `"quoted"`: `\"quoted\"`, + "line\nbreak": `line\nbreak`, + "tab\there": `tab\there`, + "": ``, + } + for in, want := range cases { + if got := jsonEscape(in); got != want { + t.Errorf("jsonEscape(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/httpsrv/portal_tryit.go b/pkg/httpsrv/portal_tryit.go new file mode 100644 index 0000000..b34a0c8 --- /dev/null +++ b/pkg/httpsrv/portal_tryit.go @@ -0,0 +1,218 @@ +// Try-It dispatch handler. Takes an operator-constructed request from +// the portal (method + path-params + query + headers + body), routes +// it into the local mux through PortalAPI.replayTarget, and returns +// the captured response. Distinct from audit replay (which reconstructs +// a request from an audit_payloads row); this is "operator authors a +// fresh request from the endpoint catalog UI." + +package httpsrv + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const ( + // tryItHeaderMarker is set on every dispatched request so the audit + // middleware (and any future loop guard) can tell a Try-It dispatch + // apart from external traffic. The value is "true" — the operator's + // identity is already on the audit row via the inbound auth chain. + tryItHeaderMarker = "X-Plexara-Try-It" + + // tryItMaxBodyBytes caps both the inbound request body we'll send + // and the response body we'll capture. Matches the replay cap; both + // surfaces share the same "operator-authored request through the + // portal" envelope, so the same limit applies. + tryItMaxBodyBytes = 1 << 20 // 1 MiB +) + +// TryItRequest is the JSON body the SPA sends. +type TryItRequest struct { + // Method overrides the registered route's Method. Most routes + // accept exactly one method; this is mostly here for the echo + // group (registered once per supported verb). + Method string `json:"method,omitempty"` + + // PathParams maps each "{name}" placeholder in the route's Path + // to its substitution. Missing params → 400. + PathParams map[string]string `json:"path_params,omitempty"` + + // QueryParams maps query-parameter names to values. Each value + // can repeat. + QueryParams map[string][]string `json:"query_params,omitempty"` + + // Headers maps header names to values. Cookie and authorization + // headers are silently dropped; operators authenticate at the + // portal level, not by injecting credentials into the dispatched + // request. + Headers map[string][]string `json:"headers,omitempty"` + + // Body is the raw request body (already serialized by the SPA). + // Empty for GET-style routes. + Body string `json:"body,omitempty"` +} + +// TryItResponse is the JSON envelope returned to the SPA. +type TryItResponse struct { + DispatchedTo string `json:"dispatched_to"` + Method string `json:"method"` + Status int `json:"status"` + Headers map[string][]string `json:"headers"` + Body string `json:"body"` + BodyTruncated bool `json:"body_truncated"` +} + +// pathParamRe matches "{name}" placeholders in route templates. +var pathParamRe = regexp.MustCompile(`\{([^}/]+)\}`) + +// disallowedTryItHeaders are dropped from the operator-supplied +// header map before dispatch. The portal session already authenticates +// the operator; injecting Cookie/Authorization into the dispatched +// request would let a low-privilege operator escalate via /v1/* auth +// surfaces. The X-API-Key path is left allowed because it's the +// documented test-fixture credential channel. +var disallowedTryItHeaders = map[string]struct{}{ + "cookie": {}, + "authorization": {}, + "set-cookie": {}, +} + +// tryIt dispatches an operator-constructed request through the local +// mux and returns the captured response. +func (p *PortalAPI) tryIt(w http.ResponseWriter, r *http.Request) { + if p.replayTarget == nil { + writeError(w, http.StatusServiceUnavailable, + errors.New("try-it disabled: composition did not supply a dispatch target")) + return + } + + group := r.PathValue("group") + routeName := r.PathValue("route") + + meta, ok := p.findRoute(group, routeName) + if !ok { + writeError(w, http.StatusNotFound, + fmt.Errorf("endpoint %s/%s not registered", group, routeName)) + return + } + + var req TryItRequest + if r.ContentLength > 0 || r.Header.Get("Content-Type") != "" { + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, tryItMaxBodyBytes)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("decode request body: %w", err)) + return + } + } + + method := req.Method + if method == "" { + method = meta.Method + } + + path, err := substitutePathParams(meta.Path, req.PathParams) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + u, err := url.Parse(path) + if err != nil { + writeError(w, http.StatusInternalServerError, fmt.Errorf("parse path: %w", err)) + return + } + if len(req.QueryParams) > 0 { + values := url.Values{} + for k, vs := range req.QueryParams { + for _, v := range vs { + values.Add(k, v) + } + } + u.RawQuery = values.Encode() + } + + body := []byte(req.Body) + if len(body) > tryItMaxBodyBytes { + body = body[:tryItMaxBodyBytes] + } + dispatched, err := http.NewRequestWithContext(r.Context(), + method, u.String(), bytes.NewReader(body)) + if err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("construct request: %w", err)) + return + } + + for k, vs := range req.Headers { + if _, banned := disallowedTryItHeaders[strings.ToLower(k)]; banned { + continue + } + for _, v := range vs { + dispatched.Header.Add(k, v) + } + } + dispatched.Header.Set(tryItHeaderMarker, "true") + + rec := newCapResponseWriter(tryItMaxBodyBytes) + p.replayTarget.ServeHTTP(rec, dispatched) + + writeJSON(w, http.StatusOK, TryItResponse{ + DispatchedTo: u.String(), + Method: method, + Status: rec.status, + Headers: rec.headers, + Body: rec.body.String(), + BodyTruncated: rec.truncated, + }) +} + +// findRoute locates the EndpointMeta whose Group and Name match. Both +// match exactly; the operator is expected to copy these from the +// portal's endpoints catalog (/api/v1/portal/endpoints), not type them. +func (p *PortalAPI) findRoute(group, routeName string) (endpoints.EndpointMeta, bool) { + if p.registry == nil { + return endpoints.EndpointMeta{}, false + } + for _, m := range p.registry.All() { + if m.Group == group && m.Name == routeName { + return m, true + } + } + return endpoints.EndpointMeta{}, false +} + +// substitutePathParams replaces each "{name}" in template with +// params[name]. Returns an error if a placeholder has no value, if a +// value would change the segment count (contains "/"), or if a value +// is empty. +func substitutePathParams(template string, params map[string]string) (string, error) { + missing := []string{} + out := pathParamRe.ReplaceAllStringFunc(template, func(match string) string { + name := match[1 : len(match)-1] + v, ok := params[name] + if !ok || v == "" { + missing = append(missing, name) + return match + } + if strings.Contains(v, "/") { + missing = append(missing, name+" (contains '/')") + return match + } + return url.PathEscape(v) + }) + if len(missing) > 0 { + return "", fmt.Errorf("missing or invalid path params: %s", + strings.Join(missing, ", ")) + } + return out, nil +} + +// capResponseWriter is defined in portal_audit_replay.go and reused +// here. Both the Try-It dispatch and audit replay paths need bounded +// response buffering with the same semantics. diff --git a/pkg/httpsrv/portal_tryit_test.go b/pkg/httpsrv/portal_tryit_test.go new file mode 100644 index 0000000..f5619f9 --- /dev/null +++ b/pkg/httpsrv/portal_tryit_test.go @@ -0,0 +1,334 @@ +package httpsrv + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +// tryItRegistry returns a registry with one stub group whose route +// catalogs match the Try-It handler's lookup-by-(group, name) contract. +func tryItRegistry(t *testing.T) *endpoints.Registry { + t.Helper() + r := endpoints.NewRegistry() + r.Add(tryItStubGroup{}) + return r +} + +type tryItStubGroup struct{} + +func (tryItStubGroup) Name() string { return "stub" } +func (tryItStubGroup) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{ + {Name: "ping", Group: "stub", Method: http.MethodGet, Path: "/v1/ping"}, + {Name: "fixed", Group: "stub", Method: http.MethodGet, Path: "/v1/fixed/{key}"}, + {Name: "create", Group: "stub", Method: http.MethodPost, Path: "/v1/create"}, + } +} +func (tryItStubGroup) Mount(*http.ServeMux, endpoints.Middleware) {} + +// tryItStubTarget returns a mux that echoes the dispatched request as +// JSON so tests can verify Try-It built the request correctly. +func tryItStubTarget(t *testing.T) *http.ServeMux { + t.Helper() + mux := http.NewServeMux() + echo := func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "query": r.URL.Query(), + "body": string(body), + "x_custom": r.Header.Get("X-Custom"), + "x_tryit": r.Header.Get(tryItHeaderMarker), + "cookie": r.Header.Get("Cookie"), + "authz": r.Header.Get("Authorization"), + }) + } + mux.HandleFunc("GET /v1/ping", echo) + mux.HandleFunc("GET /v1/fixed/{key}", echo) + mux.HandleFunc("POST /v1/create", echo) + return mux +} + +func TestTryIt_DispatchesGetWithQuery(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + p.WithDispatchTarget(tryItStubTarget(t)) + + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + body := mustJSON(t, TryItRequest{ + QueryParams: map[string][]string{ + "a": {"1", "2"}, + "b": {"x"}, + }, + }) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/ping", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d (body=%s)", w.Code, w.Body.String()) + } + + var resp TryItResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Method != http.MethodGet { + t.Errorf("method = %q, want GET", resp.Method) + } + if resp.Status != 200 { + t.Errorf("status = %d, want 200", resp.Status) + } + if !strings.Contains(resp.DispatchedTo, "a=1&a=2&b=x") { + t.Errorf("dispatched_to should contain query: %q", resp.DispatchedTo) + } + + var echoed map[string]any + _ = json.Unmarshal([]byte(resp.Body), &echoed) + if echoed["method"] != "GET" || echoed["path"] != "/v1/ping" { + t.Errorf("dispatched request shape wrong: %+v", echoed) + } + if echoed["x_tryit"] != "true" { + t.Errorf("dispatched request missing Try-It marker header: %v", echoed["x_tryit"]) + } +} + +func TestTryIt_SubstitutesPathParams(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + p.WithDispatchTarget(tryItStubTarget(t)) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + body := mustJSON(t, TryItRequest{PathParams: map[string]string{"key": "my key"}}) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/fixed", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d (body=%s)", w.Code, w.Body.String()) + } + + var resp TryItResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + // URL escaping turns the space into %20. + if !strings.Contains(resp.DispatchedTo, "/v1/fixed/my%20key") { + t.Errorf("dispatched_to did not escape path param: %q", resp.DispatchedTo) + } +} + +func TestTryIt_MissingPathParam(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + p.WithDispatchTarget(tryItStubTarget(t)) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + body := mustJSON(t, TryItRequest{}) // no PathParams + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/fixed", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400", w.Code) + } +} + +func TestTryIt_PathParamWithSlash_Refused(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + p.WithDispatchTarget(tryItStubTarget(t)) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + body := mustJSON(t, TryItRequest{ + PathParams: map[string]string{"key": "a/b/c"}, + }) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/fixed", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("status %d, want 400 (slash in path param must be refused)", w.Code) + } +} + +func TestTryIt_SendsBody(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + p.WithDispatchTarget(tryItStubTarget(t)) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + payload := `{"hi":1}` + body := mustJSON(t, TryItRequest{Body: payload}) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/create", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + + var resp TryItResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + var echoed map[string]any + _ = json.Unmarshal([]byte(resp.Body), &echoed) + if echoed["body"] != payload { + t.Errorf("dispatched body = %q, want %q", echoed["body"], payload) + } +} + +func TestTryIt_StripsCookieAndAuthorization(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + p.WithDispatchTarget(tryItStubTarget(t)) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + body := mustJSON(t, TryItRequest{ + Headers: map[string][]string{ + "Cookie": {"session=abc"}, + "Authorization": {"Bearer leaked"}, + "X-Custom": {"kept"}, + }, + }) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/ping", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var resp TryItResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + var echoed map[string]any + _ = json.Unmarshal([]byte(resp.Body), &echoed) + if echoed["cookie"] != "" { + t.Errorf("Cookie should be stripped, got %q", echoed["cookie"]) + } + if echoed["authz"] != "" { + t.Errorf("Authorization should be stripped, got %q", echoed["authz"]) + } + if echoed["x_custom"] != "kept" { + t.Errorf("X-Custom should pass through, got %q", echoed["x_custom"]) + } +} + +func TestTryIt_UnknownRoute_404(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + p.WithDispatchTarget(tryItStubTarget(t)) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/no-such-route", nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("status %d, want 404", w.Code) + } +} + +func TestTryIt_DisabledWhenNoDispatchTarget(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + // Do NOT call WithDispatchTarget; replicate the route registration + // directly so the auto-wire in Mount doesn't override. + mux := http.NewServeMux() + mux.Handle("POST /api/v1/portal/tryit/{group}/{route}", + passthroughMW(requireCSRFHeader(http.HandlerFunc(p.tryIt)))) + + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/ping", nil) + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("status %d, want 503", w.Code) + } +} + +func TestTryIt_CapsResponseBody(t *testing.T) { + p := NewPortalAPI(nil, tryItRegistry(t), nil, nil) + target := http.NewServeMux() + target.HandleFunc("GET /v1/ping", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + chunk := bytes.Repeat([]byte("A"), 32*1024) + for i := 0; i < 160; i++ { // ~5 MiB + _, _ = w.Write(chunk) + } + }) + p.WithDispatchTarget(target) + mux := http.NewServeMux() + p.Mount(mux, passthroughMW) + + body := mustJSON(t, TryItRequest{}) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/portal/tryit/stub/ping", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Requested-With", "XMLHttpRequest") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var resp TryItResponse + _ = json.Unmarshal(w.Body.Bytes(), &resp) + if !resp.BodyTruncated { + t.Errorf("body_truncated = false; expected true for >1 MiB response") + } + if len(resp.Body) > tryItMaxBodyBytes { + t.Errorf("captured body is %d bytes; cap is %d", len(resp.Body), tryItMaxBodyBytes) + } +} + +func TestSubstitutePathParams(t *testing.T) { + cases := []struct { + template string + params map[string]string + want string + wantErr bool + }{ + {"/v1/ping", nil, "/v1/ping", false}, + {"/v1/fixed/{key}", map[string]string{"key": "abc"}, "/v1/fixed/abc", false}, + {"/v1/fixed/{key}", map[string]string{"key": "a b"}, "/v1/fixed/a%20b", false}, + {"/v1/fixed/{key}", map[string]string{}, "", true}, // missing + {"/v1/fixed/{key}", map[string]string{"key": ""}, "", true}, // empty + {"/v1/fixed/{key}", map[string]string{"key": "a/b"}, "", true}, // slash + {"/v1/{a}/{b}", map[string]string{"a": "x", "b": "y"}, "/v1/x/y", false}, + } + for _, c := range cases { + t.Run(c.template, func(t *testing.T) { + got, err := substitutePathParams(c.template, c.params) + if (err != nil) != c.wantErr { + t.Errorf("err = %v, wantErr = %v", err, c.wantErr) + } + if !c.wantErr && got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} diff --git a/pkg/oapi/build.go b/pkg/oapi/build.go new file mode 100644 index 0000000..df23119 --- /dev/null +++ b/pkg/oapi/build.go @@ -0,0 +1,216 @@ +package oapi + +import ( + "reflect" + "regexp" + "sort" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +// BuildOptions controls Document generation. +type BuildOptions struct { + Info Info + Servers []Server + // APIKeyHeader is the inbound API-key header name. When set, an + // apiKey security scheme is emitted under components and applied + // to every route whose EndpointMeta.AuthRequired is true. + APIKeyHeader string + // BearerEnabled adds an HTTP Bearer security scheme alongside the + // API-key scheme; either credential type satisfies AuthRequired + // routes. + BearerEnabled bool +} + +// Build assembles a Document from the registry's routes. +// +// Each EndpointMeta produces one Operation under paths[path][method]. +// Path parameters are extracted from "{name}" segments in the path and +// typed via PathParams when supplied (defaulting to string). Query +// parameters come from QueryParams' struct fields. Request and response +// shapes come from RequestBody and ResponseBody respectively; both +// default to application/json. +func Build(reg *endpoints.Registry, opts BuildOptions) Document { + doc := Document{ + OpenAPI: "3.1.0", + Info: opts.Info, + Servers: opts.Servers, + Paths: map[string]PathItem{}, + } + + doc.Components = componentsFor(opts) + doc.Tags = tagsFor(reg) + + for _, group := range reg.Groups() { + for _, route := range group.Routes() { + pathItem := doc.Paths[route.Path] + op := operationFor(route, opts) + assignOperation(&pathItem, route.Method, op) + doc.Paths[route.Path] = pathItem + } + } + return doc +} + +func componentsFor(opts BuildOptions) *Components { + schemes := map[string]SecurityScheme{} + if opts.APIKeyHeader != "" { + schemes["apiKey"] = SecurityScheme{ + Type: "apiKey", + In: "header", + Name: opts.APIKeyHeader, + Description: "Inbound API key header. Configured by api_keys.header_name.", + } + } + if opts.BearerEnabled { + schemes["bearer"] = SecurityScheme{ + Type: "http", + Scheme: "bearer", + Description: "Inbound bearer token validated against the static bearer.tokens list or the OIDC validator when enabled.", + } + } + if len(schemes) == 0 { + return nil + } + return &Components{SecuritySchemes: schemes} +} + +func tagsFor(reg *endpoints.Registry) []Tag { + seen := map[string]bool{} + tags := make([]Tag, 0, len(reg.Groups())) + for _, g := range reg.Groups() { + name := g.Name() + if seen[name] { + continue + } + seen[name] = true + tags = append(tags, Tag{Name: name}) + } + sort.Slice(tags, func(i, j int) bool { return tags[i].Name < tags[j].Name }) + return tags +} + +func operationFor(route endpoints.EndpointMeta, opts BuildOptions) *Operation { + op := &Operation{ + OperationID: route.Name, + Description: route.Description, + Tags: []string{route.Group}, + Parameters: parametersFor(route), + Responses: responsesFor(route), + } + if route.RequestBody != nil { + op.RequestBody = &RequestBody{ + Required: true, + Content: map[string]MediaType{ + "application/json": {Schema: schemaFromType(reflect.TypeOf(route.RequestBody))}, + }, + } + } + if route.AuthRequired { + op.Security = securityRequirement(opts) + } + return op +} + +// pathParamPattern matches "{name}" segments in a route path. Cross-segment +// patterns ("{name...}") are not supported by the registry today, mirroring +// matchSegments() in pkg/endpoints/registry.go. +var pathParamPattern = regexp.MustCompile(`\{([^}/]+)\}`) + +func parametersFor(route endpoints.EndpointMeta) []Parameter { + var params []Parameter + + for _, m := range pathParamPattern.FindAllStringSubmatch(route.Path, -1) { + name := m[1] + schema := pathParamSchema(name, route.PathParams) + params = append(params, Parameter{ + Name: name, + In: "path", + Required: true, + Schema: schema, + }) + } + + if route.QueryParams != nil { + t := reflect.TypeOf(route.QueryParams) + for t != nil && t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t != nil && t.Kind() == reflect.Struct { + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + name, omitEmpty, skip := parseJSONTag(f) + if skip { + continue + } + params = append(params, Parameter{ + Name: name, + In: "query", + Required: !omitEmpty, + Schema: schemaFromType(f.Type), + }) + } + } + } + return params +} + +func pathParamSchema(name string, pathParams any) *Schema { + if pathParams == nil { + return &Schema{Type: "string"} + } + t := reflect.TypeOf(pathParams) + if ft, ok := lookupFieldByJSONName(t, name); ok { + return schemaFromType(ft) + } + return &Schema{Type: "string"} +} + +func responsesFor(route endpoints.EndpointMeta) map[string]Response { + resp := map[string]Response{} + if route.ResponseBody != nil { + resp["200"] = Response{ + Description: "Success", + Content: map[string]MediaType{ + "application/json": {Schema: schemaFromType(reflect.TypeOf(route.ResponseBody))}, + }, + } + } else { + resp["200"] = Response{Description: "Success"} + } + return resp +} + +func securityRequirement(opts BuildOptions) []map[string][]string { + var out []map[string][]string + if opts.APIKeyHeader != "" { + out = append(out, map[string][]string{"apiKey": {}}) + } + if opts.BearerEnabled { + out = append(out, map[string][]string{"bearer": {}}) + } + return out +} + +func assignOperation(p *PathItem, method string, op *Operation) { + switch strings.ToUpper(method) { + case "GET": + p.Get = op + case "POST": + p.Post = op + case "PUT": + p.Put = op + case "PATCH": + p.Patch = op + case "DELETE": + p.Delete = op + case "HEAD": + p.Head = op + case "OPTIONS": + p.Options = op + } +} diff --git a/pkg/oapi/build_test.go b/pkg/oapi/build_test.go new file mode 100644 index 0000000..f8478b5 --- /dev/null +++ b/pkg/oapi/build_test.go @@ -0,0 +1,252 @@ +package oapi + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +// fakeGroup is a minimal endpoints.Endpoints implementation used to +// exercise Build without depending on a concrete group package. +type fakeGroup struct { + name string + routes []endpoints.EndpointMeta +} + +func (g fakeGroup) Name() string { return g.name } +func (g fakeGroup) Routes() []endpoints.EndpointMeta { return g.routes } +func (g fakeGroup) Mount(_ *http.ServeMux, _ endpoints.Middleware) {} + +type itemPath struct { + Key string `json:"key"` +} + +type itemQuery struct { + Limit int `json:"limit"` + Cursor string `json:"cursor,omitempty"` +} + +type itemBody struct { + Note string `json:"note"` +} + +type itemResp struct { + Key string `json:"key"` + Value string `json:"value"` +} + +func newTestRegistry() *endpoints.Registry { + reg := endpoints.NewRegistry() + reg.Add(fakeGroup{ + name: "items", + routes: []endpoints.EndpointMeta{ + { + Name: "get_item", + Group: "items", + Method: http.MethodGet, + Path: "/v1/items/{key}", + Description: "Fetch one item by key.", + PathParams: (*itemPath)(nil), + QueryParams: (*itemQuery)(nil), + ResponseBody: (*itemResp)(nil), + AuthRequired: true, + }, + { + Name: "put_item", + Group: "items", + Method: http.MethodPut, + Path: "/v1/items/{key}", + PathParams: (*itemPath)(nil), + RequestBody: (*itemBody)(nil), + ResponseBody: (*itemResp)(nil), + }, + }, + }) + return reg +} + +func TestBuild_BasicShape(t *testing.T) { + reg := newTestRegistry() + doc := Build(reg, BuildOptions{ + Info: Info{Title: "api-test", Version: "v0"}, + Servers: []Server{{URL: "http://localhost:8080"}}, + APIKeyHeader: "X-API-Key", + }) + + if doc.OpenAPI != "3.1.0" { + t.Errorf("OpenAPI = %q, want 3.1.0", doc.OpenAPI) + } + if doc.Info.Title != "api-test" { + t.Errorf("Info.Title = %q", doc.Info.Title) + } + if got := len(doc.Paths); got != 1 { + t.Fatalf("Paths count = %d, want 1 (same path for GET+PUT)", got) + } + + item := doc.Paths["/v1/items/{key}"] + if item.Get == nil || item.Put == nil { + t.Fatalf("expected both GET and PUT under /v1/items/{key}") + } + if item.Get.OperationID != "get_item" { + t.Errorf("Get.OperationID = %q", item.Get.OperationID) + } + if item.Get.Tags[0] != "items" { + t.Errorf("Get.Tags[0] = %q", item.Get.Tags[0]) + } +} + +func TestBuild_PathAndQueryParams(t *testing.T) { + doc := Build(newTestRegistry(), BuildOptions{ + Info: Info{Title: "t", Version: "v0"}, + APIKeyHeader: "X-API-Key", + }) + get := doc.Paths["/v1/items/{key}"].Get + if get == nil { + t.Fatal("GET nil") + } + + var sawPath, sawLimit, sawCursor bool + for _, p := range get.Parameters { + switch { + case p.Name == "key" && p.In == "path": + sawPath = true + if !p.Required { + t.Errorf("path param key should be required") + } + if p.Schema.Type != "string" { + t.Errorf("path param key schema = %+v", p.Schema) + } + case p.Name == "limit" && p.In == "query": + sawLimit = true + if !p.Required { + t.Errorf("limit (no omitempty) should be required") + } + if p.Schema.Type != "integer" { + t.Errorf("limit schema type = %q", p.Schema.Type) + } + case p.Name == "cursor" && p.In == "query": + sawCursor = true + if p.Required { + t.Errorf("cursor (omitempty) should not be required") + } + } + } + if !sawPath || !sawLimit || !sawCursor { + t.Errorf("missing param: path=%v limit=%v cursor=%v (params=%+v)", + sawPath, sawLimit, sawCursor, get.Parameters) + } +} + +func TestBuild_RequestAndResponse(t *testing.T) { + doc := Build(newTestRegistry(), BuildOptions{ + Info: Info{Title: "t", Version: "v0"}, + APIKeyHeader: "X-API-Key", + }) + put := doc.Paths["/v1/items/{key}"].Put + if put == nil { + t.Fatal("PUT nil") + } + if put.RequestBody == nil { + t.Fatal("PUT.RequestBody nil") + } + media, ok := put.RequestBody.Content["application/json"] + if !ok || media.Schema == nil || media.Schema.Type != "object" { + t.Errorf("request body shape wrong: %+v", put.RequestBody) + } + if media.Schema.Properties["note"].Type != "string" { + t.Errorf("request body note schema wrong") + } + + resp, ok := put.Responses["200"] + if !ok { + t.Fatal("missing 200 response") + } + rmedia, ok := resp.Content["application/json"] + if !ok || rmedia.Schema == nil { + t.Fatal("response 200 application/json missing") + } + if rmedia.Schema.Properties["value"].Type != "string" { + t.Errorf("response shape wrong") + } +} + +func TestBuild_Security(t *testing.T) { + doc := Build(newTestRegistry(), BuildOptions{ + Info: Info{Title: "t", Version: "v0"}, + APIKeyHeader: "X-API-Key", + BearerEnabled: true, + }) + if doc.Components == nil || doc.Components.SecuritySchemes == nil { + t.Fatal("expected components.securitySchemes") + } + if doc.Components.SecuritySchemes["apiKey"].In != "header" { + t.Errorf("apiKey scheme not header") + } + if doc.Components.SecuritySchemes["bearer"].Scheme != "bearer" { + t.Errorf("bearer scheme name wrong") + } + + get := doc.Paths["/v1/items/{key}"].Get + if len(get.Security) == 0 { + t.Errorf("auth_required op should carry security requirement") + } + put := doc.Paths["/v1/items/{key}"].Put + if len(put.Security) != 0 { + t.Errorf("non-auth_required op should have no security: %+v", put.Security) + } +} + +func TestBuild_TagsSorted(t *testing.T) { + reg := endpoints.NewRegistry() + reg.Add(fakeGroup{name: "zeta"}) + reg.Add(fakeGroup{name: "alpha"}) + reg.Add(fakeGroup{name: "mu"}) + doc := Build(reg, BuildOptions{Info: Info{Title: "t", Version: "v0"}}) + got := make([]string, 0, len(doc.Tags)) + for _, tg := range doc.Tags { + got = append(got, tg.Name) + } + want := []string{"alpha", "mu", "zeta"} + for i, w := range want { + if got[i] != w { + t.Errorf("Tags[%d]=%q, want %q (got %v)", i, got[i], w, got) + } + } +} + +func TestRenderJSON_RoundTrip(t *testing.T) { + doc := Build(newTestRegistry(), BuildOptions{ + Info: Info{Title: "t", Version: "v0"}, + APIKeyHeader: "X-API-Key", + }) + b, err := RenderJSON(doc) + if err != nil { + t.Fatalf("RenderJSON: %v", err) + } + var back map[string]any + if err := json.Unmarshal(b, &back); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if back["openapi"] != "3.1.0" { + t.Errorf("openapi field missing/wrong: %v", back["openapi"]) + } + paths, ok := back["paths"].(map[string]any) + if !ok || paths["/v1/items/{key}"] == nil { + t.Errorf("paths missing: %v", back["paths"]) + } +} + +func TestRenderYAML(t *testing.T) { + doc := Build(newTestRegistry(), BuildOptions{ + Info: Info{Title: "t", Version: "v0"}, + }) + b, err := RenderYAML(doc) + if err != nil { + t.Fatalf("RenderYAML: %v", err) + } + if len(b) == 0 { + t.Fatalf("empty YAML output") + } +} diff --git a/pkg/oapi/doc.go b/pkg/oapi/doc.go new file mode 100644 index 0000000..26b1b59 --- /dev/null +++ b/pkg/oapi/doc.go @@ -0,0 +1,121 @@ +// Package oapi generates an OpenAPI 3.1 document from the api-test +// endpoint registry. The same EndpointMeta values the portal renders are +// reflected over here to produce the spec served at /openapi.json and +// /openapi.yaml. +// +// The generator is intentionally minimal: it covers the shapes the +// shipped endpoint groups actually use, not the full OpenAPI surface. +// New shapes get added as endpoint groups need them. Boot-time +// self-check (selfcheck.go) prevents the served mux from drifting +// from the published document. +package oapi + +// Document is the top-level OpenAPI 3.1 object. +type Document struct { + OpenAPI string `json:"openapi" yaml:"openapi"` + Info Info `json:"info" yaml:"info"` + Servers []Server `json:"servers,omitempty" yaml:"servers,omitempty"` + Paths map[string]PathItem `json:"paths" yaml:"paths"` + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` + Security []map[string][]string `json:"security,omitempty" yaml:"security,omitempty"` + Tags []Tag `json:"tags,omitempty" yaml:"tags,omitempty"` +} + +// Info is the OpenAPI info object. +type Info struct { + Title string `json:"title" yaml:"title"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Version string `json:"version" yaml:"version"` +} + +// Server is one entry in the servers list. +type Server struct { + URL string `json:"url" yaml:"url"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` +} + +// Tag is one entry in the top-level tags list. +type Tag struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` +} + +// PathItem groups operations registered against a single path. +type PathItem struct { + Get *Operation `json:"get,omitempty" yaml:"get,omitempty"` + Post *Operation `json:"post,omitempty" yaml:"post,omitempty"` + Put *Operation `json:"put,omitempty" yaml:"put,omitempty"` + Patch *Operation `json:"patch,omitempty" yaml:"patch,omitempty"` + Delete *Operation `json:"delete,omitempty" yaml:"delete,omitempty"` + Head *Operation `json:"head,omitempty" yaml:"head,omitempty"` + Options *Operation `json:"options,omitempty" yaml:"options,omitempty"` +} + +// Operation is one (method, path) endpoint. +type Operation struct { + OperationID string `json:"operationId" yaml:"operationId"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + Parameters []Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` + RequestBody *RequestBody `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` + Responses map[string]Response `json:"responses" yaml:"responses"` + Security []map[string][]string `json:"security,omitempty" yaml:"security,omitempty"` +} + +// Parameter is a path or query parameter on an operation. +type Parameter struct { + Name string `json:"name" yaml:"name"` + In string `json:"in" yaml:"in"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"` +} + +// RequestBody is an operation's request body declaration. +type RequestBody struct { + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Required bool `json:"required,omitempty" yaml:"required,omitempty"` + Content map[string]MediaType `json:"content" yaml:"content"` +} + +// Response is an entry in operation.responses. +type Response struct { + Description string `json:"description" yaml:"description"` + Content map[string]MediaType `json:"content,omitempty" yaml:"content,omitempty"` +} + +// MediaType holds a schema for one content-type variant. +type MediaType struct { + Schema *Schema `json:"schema,omitempty" yaml:"schema,omitempty"` +} + +// Components holds reusable definitions referenced from operations. +type Components struct { + SecuritySchemes map[string]SecurityScheme `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` +} + +// SecurityScheme describes one authentication mechanism. +type SecurityScheme struct { + Type string `json:"type" yaml:"type"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` +} + +// Schema is the JSON Schema subset the generator emits. OpenAPI 3.1 +// aligns with JSON Schema 2020-12; this struct covers the subset the +// reflector produces. Additional fields can be added as needed. +type Schema struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Format string `json:"format,omitempty" yaml:"format,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Properties map[string]*Schema `json:"properties,omitempty" yaml:"properties,omitempty"` + Required []string `json:"required,omitempty" yaml:"required,omitempty"` + Items *Schema `json:"items,omitempty" yaml:"items,omitempty"` + AdditionalProperties *Schema `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` + Nullable bool `json:"nullable,omitempty" yaml:"nullable,omitempty"` + Example any `json:"example,omitempty" yaml:"example,omitempty"` + Enum []any `json:"enum,omitempty" yaml:"enum,omitempty"` +} diff --git a/pkg/oapi/render.go b/pkg/oapi/render.go new file mode 100644 index 0000000..ba5992f --- /dev/null +++ b/pkg/oapi/render.go @@ -0,0 +1,17 @@ +package oapi + +import ( + "encoding/json" + + "gopkg.in/yaml.v3" +) + +// RenderJSON returns the OpenAPI document as pretty-printed JSON. +func RenderJSON(doc Document) ([]byte, error) { + return json.MarshalIndent(doc, "", " ") +} + +// RenderYAML returns the OpenAPI document as YAML. +func RenderYAML(doc Document) ([]byte, error) { + return yaml.Marshal(doc) +} diff --git a/pkg/oapi/schema.go b/pkg/oapi/schema.go new file mode 100644 index 0000000..28c6070 --- /dev/null +++ b/pkg/oapi/schema.go @@ -0,0 +1,137 @@ +package oapi + +import ( + "reflect" + "strings" + "time" +) + +// schemaFromType returns a *Schema for the given Go type. The reflector +// honors JSON tags (rename, omit, omitempty) and recurses through pointer, +// struct, slice, array, and map types. +// +// Unsupported or untyped values (interface{}, channels, funcs) emit an +// empty schema, which JSON Schema treats as "any value". That's the +// correct shape for echo's Body field, which holds arbitrary JSON. +func schemaFromType(t reflect.Type) *Schema { + if t == nil { + return &Schema{} + } + // Unwrap pointers; pointer-ness is encoded by request/response + // shape, not by the schema itself. + for t.Kind() == reflect.Ptr { + t = t.Elem() + } + + // time.Time renders as RFC 3339 string. + if t == reflect.TypeOf(time.Time{}) { + return &Schema{Type: "string", Format: "date-time"} + } + + switch t.Kind() { + case reflect.Bool: + return &Schema{Type: "boolean"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return &Schema{Type: "integer"} + case reflect.Float32, reflect.Float64: + return &Schema{Type: "number"} + case reflect.String: + return &Schema{Type: "string"} + case reflect.Slice, reflect.Array: + return &Schema{Type: "array", Items: schemaFromType(t.Elem())} + case reflect.Map: + // JSON only allows string-keyed maps; if a non-string key + // sneaks in, emit a generic object. + if t.Key().Kind() != reflect.String { + return &Schema{Type: "object"} + } + return &Schema{ + Type: "object", + AdditionalProperties: schemaFromType(t.Elem()), + } + case reflect.Struct: + return structSchema(t) + default: + // interface{}, chan, func, unsafe.Pointer — emit "any". + return &Schema{} + } +} + +// structSchema builds an object schema from a struct type, walking +// exported fields. JSON tags drive property naming and omission; fields +// without ",omitempty" are marked required. +func structSchema(t reflect.Type) *Schema { + out := &Schema{ + Type: "object", + Properties: map[string]*Schema{}, + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + name, omitEmpty, skip := parseJSONTag(f) + if skip { + continue + } + out.Properties[name] = schemaFromType(f.Type) + if !omitEmpty { + out.Required = append(out.Required, name) + } + } + if len(out.Properties) == 0 { + out.Properties = nil + } + return out +} + +// parseJSONTag returns the wire name, whether the field is omitempty, +// and whether the field should be skipped entirely. +func parseJSONTag(f reflect.StructField) (name string, omitEmpty bool, skip bool) { + tag := f.Tag.Get("json") + if tag == "-" { + return "", false, true + } + if tag == "" { + return f.Name, false, false + } + parts := strings.Split(tag, ",") + name = parts[0] + if name == "" { + name = f.Name + } + for _, opt := range parts[1:] { + if opt == "omitempty" { + omitEmpty = true + } + } + return name, omitEmpty, false +} + +// lookupFieldByJSONName finds the struct field whose JSON wire name +// matches target. Used to map a path parameter like {key} to the +// matching field in a PathParams struct. Returns the field's reflected +// type and whether a match was found. +func lookupFieldByJSONName(t reflect.Type, target string) (reflect.Type, bool) { + for t != nil && t.Kind() == reflect.Ptr { + t = t.Elem() + } + if t == nil || t.Kind() != reflect.Struct { + return nil, false + } + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if !f.IsExported() { + continue + } + name, _, skip := parseJSONTag(f) + if skip { + continue + } + if name == target || strings.EqualFold(name, target) { + return f.Type, true + } + } + return nil, false +} diff --git a/pkg/oapi/schema_test.go b/pkg/oapi/schema_test.go new file mode 100644 index 0000000..beb23ba --- /dev/null +++ b/pkg/oapi/schema_test.go @@ -0,0 +1,219 @@ +package oapi + +import ( + "reflect" + "testing" + "time" +) + +type sampleStruct struct { + Required string `json:"required"` + Optional int `json:"optional,omitempty"` + Renamed bool `json:"flag"` + Hidden string `json:"-"` + Untagged float64 + When time.Time `json:"when"` +} + +type nested struct { + Inner sampleStruct `json:"inner"` + List []string `json:"list"` + Bag map[string]int + Free any `json:"free,omitempty"` +} + +func TestSchemaFromType_Primitives(t *testing.T) { + cases := []struct { + name string + in any + want Schema + }{ + {"bool", false, Schema{Type: "boolean"}}, + {"int", 0, Schema{Type: "integer"}}, + {"float", 0.0, Schema{Type: "number"}}, + {"string", "", Schema{Type: "string"}}, + {"time", time.Time{}, Schema{Type: "string", Format: "date-time"}}, + {"slice", []string{}, Schema{Type: "array", Items: &Schema{Type: "string"}}}, + {"map", map[string]int{}, Schema{Type: "object", AdditionalProperties: &Schema{Type: "integer"}}}, + {"any", any(nil), Schema{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := schemaFromType(reflect.TypeOf(c.in)) + if got == nil { + t.Fatalf("schemaFromType returned nil") + } + if got.Type != c.want.Type { + t.Errorf("Type = %q, want %q", got.Type, c.want.Type) + } + if got.Format != c.want.Format { + t.Errorf("Format = %q, want %q", got.Format, c.want.Format) + } + if (got.Items == nil) != (c.want.Items == nil) { + t.Errorf("Items presence mismatch") + } else if got.Items != nil && got.Items.Type != c.want.Items.Type { + t.Errorf("Items.Type = %q, want %q", got.Items.Type, c.want.Items.Type) + } + if (got.AdditionalProperties == nil) != (c.want.AdditionalProperties == nil) { + t.Errorf("AdditionalProperties presence mismatch") + } else if got.AdditionalProperties != nil && got.AdditionalProperties.Type != c.want.AdditionalProperties.Type { + t.Errorf("AdditionalProperties.Type = %q, want %q", + got.AdditionalProperties.Type, c.want.AdditionalProperties.Type) + } + }) + } +} + +func TestSchemaFromType_Struct(t *testing.T) { + s := schemaFromType(reflect.TypeOf(sampleStruct{})) + if s.Type != "object" { + t.Fatalf("Type = %q, want object", s.Type) + } + + want := map[string]string{ + "required": "string", + "optional": "integer", + "flag": "boolean", + "Untagged": "number", + "when": "string", + } + if len(s.Properties) != len(want) { + t.Fatalf("Properties count = %d, want %d (got keys: %v)", + len(s.Properties), len(want), keys(s.Properties)) + } + for name, typ := range want { + got, ok := s.Properties[name] + if !ok { + t.Errorf("missing property %q", name) + continue + } + if got.Type != typ { + t.Errorf("property %q Type = %q, want %q", name, got.Type, typ) + } + } + if _, present := s.Properties["Hidden"]; present { + t.Errorf(`property "Hidden" should be omitted by json:"-"`) + } + if _, present := s.Properties["-"]; present { + t.Errorf(`property "-" should not appear`) + } + + requiredSet := map[string]bool{} + for _, r := range s.Required { + requiredSet[r] = true + } + wantRequired := []string{"required", "flag", "Untagged", "when"} + for _, r := range wantRequired { + if !requiredSet[r] { + t.Errorf("required missing %q (have %v)", r, s.Required) + } + } + if requiredSet["optional"] { + t.Errorf("optional should not be required (json:omitempty)") + } +} + +func TestSchemaFromType_Nested(t *testing.T) { + s := schemaFromType(reflect.TypeOf(nested{})) + if s.Type != "object" { + t.Fatalf("Type = %q, want object", s.Type) + } + inner, ok := s.Properties["inner"] + if !ok || inner.Type != "object" || inner.Properties["required"].Type != "string" { + t.Errorf("nested.inner did not recurse: %+v", inner) + } + list, ok := s.Properties["list"] + if !ok || list.Type != "array" || list.Items.Type != "string" { + t.Errorf("nested.list shape wrong: %+v", list) + } + bag, ok := s.Properties["Bag"] + if !ok || bag.Type != "object" || bag.AdditionalProperties.Type != "integer" { + t.Errorf("nested.Bag shape wrong: %+v", bag) + } + free, ok := s.Properties["free"] + if !ok || free.Type != "" { + t.Errorf("nested.free should be any (empty schema): %+v", free) + } +} + +func TestSchemaFromType_PointerUnwrap(t *testing.T) { + type holder struct { + P *string `json:"p,omitempty"` + } + s := schemaFromType(reflect.TypeOf((*holder)(nil))) + if s.Type != "object" { + t.Fatalf("Type = %q, want object", s.Type) + } + if s.Properties["p"].Type != "string" { + t.Errorf("pointer field should unwrap to string, got %+v", s.Properties["p"]) + } +} + +func TestParseJSONTag(t *testing.T) { + type sample struct { + A string `json:"a"` + B string `json:"b,omitempty"` + C string `json:"-"` + D string `json:",omitempty"` + E string + } + t.Run("plain", func(t *testing.T) { + name, omit, skip := parseJSONTag(reflect.TypeOf(sample{}).Field(0)) + if name != "a" || omit || skip { + t.Errorf("got (%q,%v,%v), want (a,false,false)", name, omit, skip) + } + }) + t.Run("omitempty", func(t *testing.T) { + name, omit, skip := parseJSONTag(reflect.TypeOf(sample{}).Field(1)) + if name != "b" || !omit || skip { + t.Errorf("got (%q,%v,%v), want (b,true,false)", name, omit, skip) + } + }) + t.Run("dash", func(t *testing.T) { + _, _, skip := parseJSONTag(reflect.TypeOf(sample{}).Field(2)) + if !skip { + t.Errorf("dash tag should skip") + } + }) + t.Run("empty-name-with-options", func(t *testing.T) { + name, omit, _ := parseJSONTag(reflect.TypeOf(sample{}).Field(3)) + if name != "D" || !omit { + t.Errorf("got (%q,%v), want (D,true)", name, omit) + } + }) + t.Run("no-tag", func(t *testing.T) { + name, omit, _ := parseJSONTag(reflect.TypeOf(sample{}).Field(4)) + if name != "E" || omit { + t.Errorf("got (%q,%v), want (E,false)", name, omit) + } + }) +} + +func TestLookupFieldByJSONName(t *testing.T) { + type params struct { + Key string `json:"key"` + ID int `json:"id"` + } + ft, ok := lookupFieldByJSONName(reflect.TypeOf(params{}), "key") + if !ok || ft.Kind() != reflect.String { + t.Errorf("key lookup got (%v,%v)", ft, ok) + } + ft, ok = lookupFieldByJSONName(reflect.TypeOf((*params)(nil)), "id") + if !ok || ft.Kind() != reflect.Int { + t.Errorf("id lookup through pointer got (%v,%v)", ft, ok) + } + if _, ok := lookupFieldByJSONName(reflect.TypeOf(params{}), "missing"); ok { + t.Errorf("missing field should return ok=false") + } + if _, ok := lookupFieldByJSONName(nil, "key"); ok { + t.Errorf("nil type should return ok=false") + } +} + +func keys(m map[string]*Schema) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/pkg/oapi/selfcheck.go b/pkg/oapi/selfcheck.go new file mode 100644 index 0000000..c2ab1ab --- /dev/null +++ b/pkg/oapi/selfcheck.go @@ -0,0 +1,91 @@ +package oapi + +import ( + "fmt" + "sort" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +// SelfCheck asserts that the rendered Document and the source Registry +// describe the same set of (method, path) routes. It catches: +// +// - oapi.Build silently dropping a route (regression in this package). +// - A future caller mutating the doc between Build and serve in a way +// that desyncs from the registry. +// +// Called at boot from the composition layer; failure aborts startup. +func SelfCheck(doc Document, reg *endpoints.Registry) error { + docRoutes := docRouteSet(doc) + regRoutes := registryRouteSet(reg) + + var missingInDoc, missingInRegistry []string + for r := range regRoutes { + if !docRoutes[r] { + missingInDoc = append(missingInDoc, r) + } + } + for r := range docRoutes { + if !regRoutes[r] { + missingInRegistry = append(missingInRegistry, r) + } + } + if len(missingInDoc) == 0 && len(missingInRegistry) == 0 { + return nil + } + sort.Strings(missingInDoc) + sort.Strings(missingInRegistry) + var parts []string + if len(missingInDoc) > 0 { + parts = append(parts, "missing from openapi doc: "+strings.Join(missingInDoc, ", ")) + } + if len(missingInRegistry) > 0 { + parts = append(parts, "missing from registry: "+strings.Join(missingInRegistry, ", ")) + } + return fmt.Errorf("openapi/registry mismatch: %s", strings.Join(parts, "; ")) +} + +func docRouteSet(doc Document) map[string]bool { + out := map[string]bool{} + for path, item := range doc.Paths { + for _, m := range pathItemMethods(item) { + out[m+" "+path] = true + } + } + return out +} + +func registryRouteSet(reg *endpoints.Registry) map[string]bool { + out := map[string]bool{} + for _, r := range reg.All() { + out[strings.ToUpper(r.Method)+" "+r.Path] = true + } + return out +} + +func pathItemMethods(p PathItem) []string { + var out []string + if p.Get != nil { + out = append(out, "GET") + } + if p.Post != nil { + out = append(out, "POST") + } + if p.Put != nil { + out = append(out, "PUT") + } + if p.Patch != nil { + out = append(out, "PATCH") + } + if p.Delete != nil { + out = append(out, "DELETE") + } + if p.Head != nil { + out = append(out, "HEAD") + } + if p.Options != nil { + out = append(out, "OPTIONS") + } + return out +} diff --git a/pkg/oapi/selfcheck_test.go b/pkg/oapi/selfcheck_test.go new file mode 100644 index 0000000..321e4f0 --- /dev/null +++ b/pkg/oapi/selfcheck_test.go @@ -0,0 +1,67 @@ +package oapi + +import ( + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func TestSelfCheck_Match(t *testing.T) { + reg := newTestRegistry() + doc := Build(reg, BuildOptions{Info: Info{Title: "t", Version: "v0"}}) + if err := SelfCheck(doc, reg); err != nil { + t.Errorf("SelfCheck: %v", err) + } +} + +func TestSelfCheck_DocMissingRoute(t *testing.T) { + reg := newTestRegistry() + doc := Build(reg, BuildOptions{Info: Info{Title: "t", Version: "v0"}}) + + // Drop one operation from the doc to simulate Build dropping a route. + item := doc.Paths["/v1/items/{key}"] + item.Put = nil + doc.Paths["/v1/items/{key}"] = item + + err := SelfCheck(doc, reg) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "missing from openapi doc") { + t.Errorf("error text wrong: %v", err) + } + if !strings.Contains(err.Error(), "PUT /v1/items/{key}") { + t.Errorf("error should name PUT route: %v", err) + } +} + +func TestSelfCheck_DocHasExtra(t *testing.T) { + reg := newTestRegistry() + doc := Build(reg, BuildOptions{Info: Info{Title: "t", Version: "v0"}}) + + // Add an operation the registry doesn't know about. + doc.Paths["/v1/ghost"] = PathItem{Get: &Operation{ + OperationID: "ghost", + Responses: map[string]Response{"200": {Description: "ok"}}, + }} + + err := SelfCheck(doc, reg) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "missing from registry") { + t.Errorf("error text wrong: %v", err) + } + if !strings.Contains(err.Error(), "GET /v1/ghost") { + t.Errorf("error should name ghost route: %v", err) + } +} + +func TestSelfCheck_EmptyRegistry(t *testing.T) { + reg := endpoints.NewRegistry() + doc := Build(reg, BuildOptions{Info: Info{Title: "t", Version: "v0"}}) + if err := SelfCheck(doc, reg); err != nil { + t.Errorf("empty registry should self-check clean: %v", err) + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b542402..42cadc4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useAuth } from "./stores/auth"; import ThemeToggle from "./components/ThemeToggle"; import { SidebarBrand, SponsoredBy } from "./components/Brand"; -import { Activity, Network, ShieldCheck, KeyRound, Settings, Info, LogOut } from "lucide-react"; +import { Activity, Network, ShieldCheck, KeyRound, Settings, Info, LogOut, BookOpen } from "lucide-react"; function prettySubject(s: string | undefined): string { if (!s) return ""; @@ -15,6 +15,7 @@ function prettySubject(s: string | undefined): string { const NAV: { to: string; label: string; icon: typeof Activity }[] = [ { to: "/", label: "Dashboard", icon: Activity }, { to: "/endpoints", label: "Endpoints", icon: Network }, + { to: "/discovery", label: "Discovery", icon: BookOpen }, { to: "/audit", label: "Audit", icon: ShieldCheck }, { to: "/keys", label: "API Keys", icon: KeyRound }, { to: "/config", label: "Config", icon: Settings }, diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 03c7db7..015ce7b 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -145,7 +145,24 @@ export type AuditPayload = { export type AuditMeta = { filters: string[]; - features: { timeseries: boolean; breakdown: boolean; stream: boolean; export: boolean; replay: boolean }; + features: { timeseries: boolean; breakdown: boolean; stats: boolean; stream: boolean; export: boolean; replay: boolean }; +}; + +export type TryItRequest = { + method?: string; + path_params?: Record; + query_params?: Record; + headers?: Record; + body?: string; +}; + +export type TryItResponse = { + dispatched_to: string; + method: string; + status: number; + headers: Record; + body: string; + body_truncated: boolean; }; export type DashboardResponse = { @@ -176,6 +193,8 @@ export const portalAPI = { auditEvent: (id: string) => api.get(`/api/v1/portal/audit/events/${encodeURIComponent(id)}`), dashboard: () => api.get("/api/v1/portal/dashboard"), wellknown: () => api.get<{ protected_resource_url: string; authorization_server: string; oidc_enabled: boolean; audience: string; api_endpoint: string }>("/api/v1/portal/wellknown"), + tryIt: (group: string, route: string, body: TryItRequest) => + api.post(`/api/v1/portal/tryit/${encodeURIComponent(group)}/${encodeURIComponent(route)}`, body), }; export const adminAPI = { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index b7d8c07..80a1a40 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -8,6 +8,7 @@ import App from "./App"; import Login from "./pages/Login"; import Dashboard from "./pages/Dashboard"; import Endpoints from "./pages/Endpoints"; +import Discovery from "./pages/Discovery"; import Audit from "./pages/Audit"; import ApiKeys from "./pages/ApiKeys"; import Config from "./pages/Config"; @@ -32,6 +33,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/Discovery.tsx b/ui/src/pages/Discovery.tsx new file mode 100644 index 0000000..978c311 --- /dev/null +++ b/ui/src/pages/Discovery.tsx @@ -0,0 +1,93 @@ +// Discovery page: embeds the Redoc viewer served at /docs (rendered +// against /openapi.json by the oapi-generator backend). The iframe +// loads the same /docs URL an operator would visit directly; the +// portal just frames it alongside the rest of the sidebar nav. + +import { useEffect, useState } from "react"; + +export default function Discovery() { + const [reachable, setReachable] = useState<"checking" | "ok" | "missing">("checking"); + + useEffect(() => { + // Probe /openapi.json so we can show a friendly empty-state if + // the operator is running a build without the oapi-generator + // surface mounted. We HEAD the JSON rather than the HTML because + // a 404 on /openapi.json is the most reliable signal that the + // OpenAPI surface isn't wired up; the /docs HTML may exist as a + // static asset even when the generator is absent. + fetch("/openapi.json", { method: "HEAD", credentials: "include" }) + .then((r) => setReachable(r.ok ? "ok" : "missing")) + .catch(() => setReachable("missing")); + }, []); + + return ( +
+
+
+

Discovery

+
+ OpenAPI 3.1 reference rendered from{" "} + /openapi.json + {" "}via{" "} + /docs. +
+
+ +
+ + {reachable === "ok" && ( + // sandbox keeps the embedded Redoc from navigating the parent + // window if a future spec extension introduces an external link + // with target=_top. allow-scripts is required for the Redoc + // bundle to render; allow-same-origin lets it fetch + // /openapi.json relative to the parent origin. +