From d32cbc870390f85a7daa253aef58db309ccb0c26 Mon Sep 17 00:00:00 2001 From: cjimti Date: Sat, 9 May 2026 16:08:43 -0700 Subject: [PATCH 1/5] scaffold api-test fixture: HTTP endpoint groups + auth + audit Sister project to mcp-test, but for HTTP API gateways instead of MCP gateways. api-test is the upstream fixture the Plexara API gateway calls; same role as mcp-test, opposite polarity. M1 (HTTP fixture skeleton): cmd/api-test, internal/server (composition root with graceful drain), internal/ui (go:embed placeholder for the M3 SPA), pkg/build, pkg/config (YAML loader with ${VAR:-default} interpolation), pkg/endpoints (Endpoints interface + Registry; identity, data, failure, echo groups), pkg/httpsrv (mux + health + CORS). Configs (dev/live/example), Makefile (40+ targets matching mcp-test conventions including the verify gate sentinel), distroless Dockerfile, README. M2 (DB + audit + non-OAuth inbound auth): pkg/database (pgxpool + golang-migrate with embedded migrations), pkg/audit (HTTP-shaped Event/Payload + Memory/Noop/Async loggers + Postgres store with Log/Query/Count/GetPayload), pkg/apikeys (bcrypt-hashed PG keys), pkg/auth/inbound (Identity, file API keys, static bearer, chain composer), pkg/httpmw (RequestID, Identity, AccessLog, Audit middleware with body capture + truncation + redaction). Integration tests under tests/ with build tag `integration` exercising auth matrix, audit capture, audit failure marking, healthz-not-audited, and query filters against testcontainers Postgres. Initial migration (0001_init) ships HTTP-shaped audit_events + audit_payloads tables (route_name, endpoint_group, method, path, status, bytes_in/out columns; payloads carry headers, query, content-type, raw bodies). 7-day default retention; export-style large bodies inflate audit_payloads fast. OIDC/Keycloak inbound, portal SPA, OpenAPI generator, and remaining endpoint groups (streaming, pagination, methods, security, export) land in M3-M5. Three pre-commit-review findings addressed: - Registry.GroupByRoute did literal method+path lookup, breaking for path-parameterized routes like /v1/fixed/{key}. Replaced with RouteForRequest(method, requestPath) + a small {name}-aware segment matcher; audit middleware now resolves both endpoint_group and route_name correctly. Test added for /v1/fixed/abc, /v1/status/503, segment-count mismatches, wrong method. - AccessLog wrapped the mux from outside but Identity ran inside the per-route chain, so r.WithContext(...) inside Identity never flowed back up. Added a per-request *identityHolder seeded by RequestID; Identity records into it; AccessLog reads from it via resolvedIdentity(). Test exercises the real composition and asserts auth_type/subject reach the access log line. - MemoryLogger silently dropped QueryFilter.Search and .Offset while the Postgres store honored both. Memory now applies case-insensitive substring on path OR error_message (mirrors Postgres ILIKE) and applies Offset before Limit-clamping. Paired-backend test covers search hit on path, case-insensitivity, search hit on error_message, paged offsets, offset past end, and negative-offset clamping. 84.5% testable-subset coverage (Postgres-dependent packages excluded since they're covered by `go test -tags integration`); make verify green; integration suite green against testcontainers Postgres. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 10 + Dockerfile | 42 ++ Makefile | 232 +++++++++++ README.md | 81 ++++ cmd/api-test/main.go | 99 +++++ configs/api-test.dev.yaml | 32 ++ configs/api-test.example.yaml | 87 ++++ configs/api-test.live.yaml | 62 +++ go.mod | 69 ++++ go.sum | 173 ++++++++ internal/server/server.go | 243 +++++++++++ internal/server/server_test.go | 277 +++++++++++++ internal/ui/dist/.gitkeep | 0 internal/ui/embed.go | 45 ++ internal/ui/embed_test.go | 16 + pkg/apikeys/inbound.go | 26 ++ pkg/apikeys/store.go | 185 +++++++++ pkg/audit/async.go | 145 +++++++ pkg/audit/async_test.go | 109 +++++ pkg/audit/event.go | 144 +++++++ pkg/audit/event_test.go | 72 ++++ pkg/audit/logger.go | 53 +++ pkg/audit/memory.go | 157 +++++++ pkg/audit/memory_test.go | 173 ++++++++ pkg/audit/postgres/store.go | 386 +++++++++++++++++ pkg/auth/inbound/apikey.go | 142 +++++++ pkg/auth/inbound/bearer.go | 68 +++ pkg/auth/inbound/chain.go | 64 +++ pkg/auth/inbound/identity.go | 65 +++ pkg/auth/inbound/inbound_test.go | 201 +++++++++ pkg/build/build.go | 11 + pkg/config/config.go | 388 ++++++++++++++++++ pkg/config/config_test.go | 144 +++++++ pkg/database/migrate/migrate.go | 59 +++ .../migrate/migrations/0001_init.down.sql | 12 + .../migrate/migrations/0001_init.up.sql | 92 +++++ pkg/database/pg.go | 41 ++ pkg/endpoints/data/data.go | 221 ++++++++++ pkg/endpoints/data/data_test.go | 134 ++++++ pkg/endpoints/echo/echo.go | 122 ++++++ pkg/endpoints/echo/echo_test.go | 126 ++++++ pkg/endpoints/failure/failure.go | 186 +++++++++ pkg/endpoints/failure/failure_test.go | 133 ++++++ pkg/endpoints/identity/identity.go | 129 ++++++ pkg/endpoints/identity/identity_test.go | 85 ++++ pkg/endpoints/registry.go | 163 ++++++++ pkg/endpoints/registry_test.go | 132 ++++++ pkg/httpmw/audit.go | 220 ++++++++++ pkg/httpmw/identity.go | 56 +++ pkg/httpmw/logger.go | 70 ++++ pkg/httpmw/middleware_test.go | 351 ++++++++++++++++ pkg/httpmw/requestid.go | 87 ++++ pkg/httpsrv/cors.go | 21 + pkg/httpsrv/health.go | 46 +++ pkg/httpsrv/health_test.go | 73 ++++ pkg/httpsrv/mux.go | 64 +++ pkg/httpsrv/mux_test.go | 75 ++++ tests/audit_test.go | 247 +++++++++++ tests/auth_matrix_test.go | 124 ++++++ tests/helpers_integration_test.go | 145 +++++++ 60 files changed, 7215 insertions(+) create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/api-test/main.go create mode 100644 configs/api-test.dev.yaml create mode 100644 configs/api-test.example.yaml create mode 100644 configs/api-test.live.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/ui/dist/.gitkeep create mode 100644 internal/ui/embed.go create mode 100644 internal/ui/embed_test.go create mode 100644 pkg/apikeys/inbound.go create mode 100644 pkg/apikeys/store.go create mode 100644 pkg/audit/async.go create mode 100644 pkg/audit/async_test.go create mode 100644 pkg/audit/event.go create mode 100644 pkg/audit/event_test.go create mode 100644 pkg/audit/logger.go create mode 100644 pkg/audit/memory.go create mode 100644 pkg/audit/memory_test.go create mode 100644 pkg/audit/postgres/store.go create mode 100644 pkg/auth/inbound/apikey.go create mode 100644 pkg/auth/inbound/bearer.go create mode 100644 pkg/auth/inbound/chain.go create mode 100644 pkg/auth/inbound/identity.go create mode 100644 pkg/auth/inbound/inbound_test.go create mode 100644 pkg/build/build.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/database/migrate/migrate.go create mode 100644 pkg/database/migrate/migrations/0001_init.down.sql create mode 100644 pkg/database/migrate/migrations/0001_init.up.sql create mode 100644 pkg/database/pg.go create mode 100644 pkg/endpoints/data/data.go create mode 100644 pkg/endpoints/data/data_test.go create mode 100644 pkg/endpoints/echo/echo.go create mode 100644 pkg/endpoints/echo/echo_test.go create mode 100644 pkg/endpoints/failure/failure.go create mode 100644 pkg/endpoints/failure/failure_test.go create mode 100644 pkg/endpoints/identity/identity.go create mode 100644 pkg/endpoints/identity/identity_test.go create mode 100644 pkg/endpoints/registry.go create mode 100644 pkg/endpoints/registry_test.go create mode 100644 pkg/httpmw/audit.go create mode 100644 pkg/httpmw/identity.go create mode 100644 pkg/httpmw/logger.go create mode 100644 pkg/httpmw/middleware_test.go create mode 100644 pkg/httpmw/requestid.go create mode 100644 pkg/httpsrv/cors.go create mode 100644 pkg/httpsrv/health.go create mode 100644 pkg/httpsrv/health_test.go create mode 100644 pkg/httpsrv/mux.go create mode 100644 pkg/httpsrv/mux_test.go create mode 100644 tests/audit_test.go create mode 100644 tests/auth_matrix_test.go create mode 100644 tests/helpers_integration_test.go diff --git a/.gitignore b/.gitignore index aaadf73..25ddf1e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,13 @@ go.work.sum # Editor/IDE # .idea/ # .vscode/ + +# Build artifacts +/bin/ +/site/ + +# Dev secrets (generated by `make dev-secrets`) +.env.dev + +# Local Claude Code per-machine settings +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f1ddbee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# Multi-stage build for the api-test fixture binary. +# +# Stage 1: build the static linux binary with version metadata stamped in. +# Stage 2: distroless base; the binary doubles as its own healthcheck via +# `--healthcheck` so we don't need curl/wget in the runtime image. + +FROM golang:1.26 AS build + +ARG TARGETARCH=amd64 +ARG VERSION=dev +ARG COMMIT=none +ARG BUILD_DATE=unknown + +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} \ + go build \ + -trimpath \ + -ldflags "-s -w \ + -X github.com/plexara/api-test/pkg/build.Version=${VERSION} \ + -X github.com/plexara/api-test/pkg/build.Commit=${COMMIT} \ + -X github.com/plexara/api-test/pkg/build.Date=${BUILD_DATE}" \ + -o /out/api-test \ + ./cmd/api-test + +FROM gcr.io/distroless/static-debian12:nonroot + +COPY --from=build /out/api-test /usr/local/bin/api-test +COPY --chown=nonroot:nonroot configs/api-test.dev.yaml /etc/api-test/api-test.yaml + +EXPOSE 8080 +USER nonroot:nonroot + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/usr/local/bin/api-test", "--healthcheck"] + +ENTRYPOINT ["/usr/local/bin/api-test"] +CMD ["--config", "/etc/api-test/api-test.yaml"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f372885 --- /dev/null +++ b/Makefile @@ -0,0 +1,232 @@ +# api-test Makefile +# +# Common targets: +# make build # build the binary into ./bin/api-test +# make test # go test -race -count=1 +# make verify # full CI-equivalent: tools-check, fmt, vet, test, lint, security, coverage +# make dev-anon # postgres-free anonymous-mode binary; fastest iteration +# +# Run `make help` to see every target. + +SHELL := /bin/bash + +BINARY_NAME := api-test + +VERSION ?= $(shell \ + tag=$$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0); \ + git diff --quiet HEAD -- 2>/dev/null && echo $$tag || echo $$tag-dirty) +GIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) +BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) + +LDFLAGS := -ldflags "-X github.com/plexara/api-test/pkg/build.Version=$(VERSION) \ + -X github.com/plexara/api-test/pkg/build.Commit=$(GIT_SHA) \ + -X github.com/plexara/api-test/pkg/build.Date=$(BUILD_DATE)" + +CMD_DIR := ./cmd/api-test +BUILD_DIR := ./bin +UI_DIR := ./ui +UI_EMBED_DIR := ./internal/ui/dist + +# Pinned tool versions; keep in sync with .github/workflows/ci.yml. +GOLANGCI_LINT_VERSION := v2.11.4 +GOSEC_VERSION := v2.25.0 + +TOOLS_DIR := $(abspath $(BUILD_DIR)/tools) + +GO := go +GOTEST := $(GO) test +GOBUILD := $(GO) build +GOMOD := $(GO) mod +GOFMT := gofmt +GOLINT := $(TOOLS_DIR)/golangci-lint +GOSEC := $(TOOLS_DIR)/gosec +GOVULN := $(TOOLS_DIR)/govulncheck + +.PHONY: all build test test-short bench fmt fmt-check vet tidy clean help dev-secrets \ + ui ui-dev ui-clean embed-clean \ + lint security gosec govulncheck \ + coverage coverage-gate coverage-report \ + verify tools-check tools-install \ + dev dev-anon dev-up dev-wait dev-ui-if-needed dev-down dev-logs \ + docker docs docs-serve run version + +## all: Build, test, lint +all: build test lint + +## build: Build the binary into ./bin/api-test +build: + @echo "Building $(BINARY_NAME)..." + @mkdir -p $(BUILD_DIR) + $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_DIR) + @echo "Binary built: $(BUILD_DIR)/$(BINARY_NAME)" + +## test: Run unit tests with race detector +test: + @echo "Running tests..." + $(GOTEST) -race -count=1 ./... + +## test-short: Skip integration / long tests (-short) +test-short: + $(GOTEST) -short -count=1 ./... + +## bench: Run benchmarks +bench: + $(GOTEST) -run=^$$ -bench=. -benchmem ./... + +## fmt: Apply gofmt -s +fmt: + @echo "Running gofmt..." + $(GOFMT) -s -w . + +## fmt-check: Fail if gofmt would change anything +fmt-check: + @echo "Checking gofmt..." + @out="$$($(GOFMT) -s -l .)"; \ + if [ -n "$$out" ]; then \ + echo "FAIL: files need 'make fmt':"; echo "$$out"; exit 1; \ + fi + @echo "gofmt clean." + +## vet: go vet +vet: + @echo "Running go vet..." + $(GO) vet ./... + +## tidy: go mod tidy +tidy: + $(GOMOD) tidy + +## lint: golangci-lint run (pinned version from $(TOOLS_DIR)) +lint: tools-check + @echo "Running golangci-lint $(GOLANGCI_LINT_VERSION)..." + $(GOLINT) run --timeout=5m + +## gosec: Static security analyzer (pinned version from $(TOOLS_DIR)) +gosec: tools-check + @echo "Running gosec $(GOSEC_VERSION)..." + $(GOSEC) -quiet ./... + +## govulncheck: Known-vulnerability scan +govulncheck: tools-check + @echo "Running govulncheck..." + $(GOVULN) ./... + +## security: gosec + govulncheck +security: gosec govulncheck + +COVERAGE_MIN ?= 80 + +## coverage: Run tests and produce a coverage profile. +coverage: + @echo "Running coverage..." + $(GOTEST) -race -coverprofile=coverage.out -covermode=atomic ./... + @$(GO) tool cover -func=coverage.out | tail -1 + +## coverage-gate: Fail if coverage of testable packages is below COVERAGE_MIN (default 80) +## Excludes Postgres-dependent packages (apikeys, audit/postgres, +## database, database/migrate) — those are covered by the +## integration test suite (go test -tags integration) which +## doesn't contribute to the unit-test coverage profile. +## Also excludes cmd/api-test (binary entry; tested manually). +COVERAGE_EXCLUDE := cmd/api-test|pkg/apikeys|pkg/audit/postgres|pkg/database +coverage-gate: coverage + @total=$$( \ + $(GO) tool cover -func=coverage.out \ + | grep -Ev "$(COVERAGE_EXCLUDE)" \ + | awk '$$3 ~ /%$$/ {gsub(/%/,"",$$3); sum+=$$3; n++} END { if (n==0) { print 0 } else { printf "%.1f", sum/n } }' \ + ); \ + awk -v total=$$total -v min=$(COVERAGE_MIN) 'BEGIN { if (total+0 < min+0) { printf "coverage (testable subset) %s%% < %s%%\n", total, min; exit 1 } else { printf "coverage (testable subset) %s%% >= %s%%\n", total, min } }' + +## tools-install: Install lint/security tools at the pinned versions into $(TOOLS_DIR). +TOOLS_STAMP := $(TOOLS_DIR)/.installed-$(GOLANGCI_LINT_VERSION)-$(GOSEC_VERSION) +tools-install: $(TOOLS_STAMP) + +$(TOOLS_STAMP): + @echo "Installing pinned tools into $(TOOLS_DIR)..." + @mkdir -p $(TOOLS_DIR) + @rm -f $(TOOLS_DIR)/.installed-* 2>/dev/null || true + GOBIN=$(TOOLS_DIR) $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + GOBIN=$(TOOLS_DIR) $(GO) install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) + GOBIN=$(TOOLS_DIR) $(GO) install golang.org/x/vuln/cmd/govulncheck@latest + @touch $@ + +## tools-check: Verify pinned tools are present at the right versions; auto-installs. +tools-check: tools-install + @echo "Tools pinned at $(TOOLS_DIR):" + @echo " golangci-lint: $$($(GOLINT) --version 2>/dev/null | head -1)" + @echo " gosec: $$($(GOSEC) --version 2>/dev/null | head -1)" + @echo " govulncheck: $$(test -x $(GOVULN) && echo present || echo MISSING)" + +## verify: Full CI-equivalent suite. Fails on any error including <80% coverage. +verify: tools-check fmt-check vet test lint security coverage-gate + @echo "" + @echo "=== verify: all checks passed ===" + @# Pre-commit gate sentinel: record the current diff hash so the + @# review-gate hook knows verify is green for this exact tree state. + @mkdir -p .claude + @{ git diff --cached HEAD 2>/dev/null; git diff 2>/dev/null; } \ + | shasum -a 256 | cut -c1-16 > .claude/.last-verify-passed + +## dev-anon: Run anonymous-mode dev binary; no DB, no auth (M1 happy path). +dev-anon: + $(GO) run $(LDFLAGS) $(CMD_DIR) --config configs/api-test.dev.yaml + +## dev-secrets: Generate .env.dev with random cookie secret + dev API key on first run. +dev-secrets: + @if [ ! -f .env.dev ]; then \ + echo "Generating .env.dev with random secrets (gitignored)..."; \ + printf 'export APITEST_COOKIE_SECRET=%s\nexport APITEST_DEV_KEY=%s\nexport APITEST_DEV_BEARER=%s\n' \ + "$$(head -c 48 /dev/urandom | base64 | tr -d '\n')" \ + "apitest_$$(head -c 24 /dev/urandom | base64 | tr -d '\n=+/' | head -c 32)" \ + "apitest_bearer_$$(head -c 24 /dev/urandom | base64 | tr -d '\n=+/' | head -c 32)" \ + > .env.dev; \ + chmod 600 .env.dev; \ + fi + +## dev: Full local stack (M3+). For now, points at dev-anon. +## M3 will replace with: postgres + keycloak in compose, binary in foreground. +dev: dev-anon + +## run: Build and run with dev config +run: build + $(BUILD_DIR)/$(BINARY_NAME) --config configs/api-test.dev.yaml + +## docker: Build the docker image (M5: matches goreleaser pipeline). +docker: build + @mkdir -p linux/amd64 + @cp $(BUILD_DIR)/$(BINARY_NAME) linux/amd64/$(BINARY_NAME) + docker buildx build --platform linux/amd64 \ + --build-arg TARGETARCH=amd64 \ + -t $(BINARY_NAME):$(VERSION) \ + --load . + @rm -rf linux/ + +## docs: Build the documentation site (M5; requires mkdocs-material). +docs: + mkdocs build --strict + +## docs-serve: Serve the documentation site locally (M5). +DOCS_HOST ?= 127.0.0.1 +DOCS_PORT ?= 8001 +docs-serve: + mkdocs serve -a $(DOCS_HOST):$(DOCS_PORT) + +## clean: Remove build artifacts +clean: + rm -rf $(BUILD_DIR) coverage.out coverage.html + +## version: Show resolved version metadata +version: + @echo "Binary: $(BINARY_NAME)" + @echo "Version: $(VERSION)" + @echo "Commit: $(GIT_SHA)" + @echo "Build date: $(BUILD_DATE)" + @echo "Go: $$($(GO) version | cut -d ' ' -f 3)" + +## help: Show this help +help: + @echo "$(BINARY_NAME) Makefile" + @echo "" + @echo "Usage: make [target]" + @echo "" + @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /' diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfe26e4 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# api-test + +A controllable HTTP REST fixture used to exercise API gateways (Plexara's +in particular). Sister project to [mcp-test](../mcp-test), which plays the +same role for the MCP gateway. + +## Why + +Plexara MCP exposes two gateway capabilities: + +- **MCP gateway** — registers upstream MCP servers as connections. Tested + by `mcp-test`. +- **API gateway** — registers upstream HTTP APIs as connections; exposes + three MCP tools (`api_invoke_endpoint`, `api_list_endpoints`, + `api_export`). Tested by **`api-test`** — this project. + +`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 +compare what a client sent through Plexara, what reached this server, and +what came back. + +## Endpoint groups (M1) + +- **identity** — `GET /v1/whoami`, `GET /v1/headers`. Verify the gateway + forwards identity, args, and HTTP headers (with redaction). +- **data** — `GET /v1/fixed/{key}`, `GET /v1/sized?bytes=N`, + `GET /v1/lorem?words=N&seed=S`. Deterministic outputs for testing + enrichment dedup, response-size handling, and caching. +- **failure** — `GET /v1/status/{code}`, `GET /v1/slow?ms=N`, + `GET /v1/flaky?fail_rate=&seed=&call_id=`. Controlled failure modes + for retry/timeout policy testing. +- **echo** — `ANY /v1/echo`. Generic catch-all that returns the request + verbatim (with auth headers redacted). + +Coming in later milestones: streaming (chunked, SSE, NDJSON), pagination +(Link, OData, cursor variants), method matrix, security probes, export +(large/long-running targets for `api_export`), the OpenAPI document, +inbound auth (bearer/api_key/OAuth2), audit log, web portal, mkdocs +site, and CI/release tooling. + +## Quickstart + +```bash +go run ./cmd/api-test --config configs/api-test.dev.yaml +# in another shell: +curl -s http://localhost:8080/v1/whoami +curl -s 'http://localhost:8080/v1/sized?bytes=64' +curl -s http://localhost:8080/v1/status/418 +curl -s -X POST http://localhost:8080/v1/echo -H 'Content-Type: application/json' -d '{"hi":1}' +``` + +`make dev-anon` does the same. `make build` produces `./bin/api-test`. + +## Tests + +```bash +go test ./... # unit + in-memory tests; no Docker required +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. + +## Layout + +``` +cmd/api-test # binary entry +internal/server # composition root (config + endpoints + httpsrv) +pkg/build # version metadata stamped at link time +pkg/config # YAML loader + ${VAR:-default} env interpolation +pkg/endpoints # Endpoints interface + registry +pkg/endpoints/{...} # one package per group (identity, data, failure, echo) +pkg/httpsrv # HTTP mux composition + health/readiness + CORS +configs/ # *.dev.yaml, *.live.yaml, *.example.yaml +``` + +## License + +Apache 2.0 — see [LICENSE](LICENSE). diff --git a/cmd/api-test/main.go b/cmd/api-test/main.go new file mode 100644 index 0000000..5ed0ebb --- /dev/null +++ b/cmd/api-test/main.go @@ -0,0 +1,99 @@ +// Command api-test is an HTTP REST test fixture used to exercise API +// gateways (Plexara's in particular). It is also a small, best-practices +// reference for an instrumented test fixture in Go. +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/plexara/api-test/internal/server" + "github.com/plexara/api-test/pkg/config" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "fatal:", err) + os.Exit(1) + } +} + +func run() error { + configPath := flag.String("config", "configs/api-test.yaml", "path to YAML config file") + address := flag.String("address", "", "override server.address (e.g. :9090)") + showVersion := flag.Bool("version", false, "print build version and exit") + healthcheck := flag.Bool("healthcheck", false, "probe http://127.0.0.1:8080/healthz and exit") + flag.Parse() + + if *showVersion { + fmt.Println(server.Version()) + return nil + } + if *healthcheck { + // Distroless images can't bundle curl/wget, so the binary doubles + // as its own healthcheck probe. Exits 0 on a 200, non-zero + // otherwise. + return runHealthcheck() + } + + logger := newLogger() + slog.SetDefault(logger) + + cfg, err := config.Load(*configPath) + if err != nil { + return err + } + if *address != "" { + cfg.Server.Address = *address + } + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + app, err := server.Build(ctx, cfg, logger) + if err != nil { + return err + } + defer app.Close() + + return app.Run(ctx) +} + +func runHealthcheck() error { + url := os.Getenv("APITEST_HEALTHCHECK_URL") + if url == "" { + url = "http://127.0.0.1:8080/healthz" + } + client := &http.Client{Timeout: 3 * time.Second} + // #nosec G107 G704 -- URL is from a trusted env var the operator sets; + // this is a self-probe of the binary's own health endpoint. + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("healthcheck: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("healthcheck: status %d", resp.StatusCode) + } + return nil +} + +func newLogger() *slog.Logger { + level := slog.LevelInfo + switch os.Getenv("LOG_LEVEL") { + case "debug", "DEBUG": + level = slog.LevelDebug + case "warn", "WARN": + level = slog.LevelWarn + case "error", "ERROR": + level = slog.LevelError + } + return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level})) +} diff --git a/configs/api-test.dev.yaml b/configs/api-test.dev.yaml new file mode 100644 index 0000000..f9c24a2 --- /dev/null +++ b/configs/api-test.dev.yaml @@ -0,0 +1,32 @@ +# api-test dev config: anonymous, no audit, no DB. Run with: +# +# go run ./cmd/api-test --config configs/api-test.dev.yaml +# +# This is the fastest path to a working binary while iterating on +# endpoints. Audit/DB/portal/auth come online with the .live.yaml profile. + +server: + name: api-test + address: ":8080" + +auth: + allow_anonymous: true + +audit: + enabled: false + +portal: + enabled: false + +endpoints: + identity: { enabled: true } + 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 } diff --git a/configs/api-test.example.yaml b/configs/api-test.example.yaml new file mode 100644 index 0000000..c1945b7 --- /dev/null +++ b/configs/api-test.example.yaml @@ -0,0 +1,87 @@ +# api-test reference config. Copy to api-test.yaml and edit. Every knob +# has a sensible default; only fields you care about overriding need to +# appear in your own file. + +server: + name: api-test + address: ":8080" + base_url: "http://localhost:8080" + read_header_timeout: 10s + shutdown: + grace_period: 25s + pre_shutdown_delay: 2s + tls: + enabled: false + cert_file: "" + key_file: "" + +# OIDC validates JWTs the Plexara gateway sends after exchanging +# client_credentials or auth_code with the IdP. Keycloak provides this in +# dev (configs/api-test.live.yaml). Disabled here. +oidc: + enabled: false + issuer: "${APITEST_OIDC_ISSUER:-http://localhost:8081/realms/api-test}" + audience: "${APITEST_OIDC_AUDIENCE:-api-test}" + allowed_clients: [] + clock_skew_seconds: 30 + jwks_cache_ttl: 1h + skip_signature_verification: false # requires APITEST_INSECURE=1 if true + +# Static API keys for inbound auth_mode=api_key (header or query). +api_keys: + header_name: "X-API-Key" + query_param_name: "api_key" + file: + - { name: "devkey", key: "${APITEST_DEV_KEY:-devkey-please-change}", description: "default dev key" } + db: + enabled: false + +# Static bearer tokens for inbound auth_mode=bearer. +bearer: + tokens: + - { name: "devbearer", token: "${APITEST_DEV_BEARER:-bearer-please-change}", description: "default dev bearer" } + +auth: + allow_anonymous: false + require_for_api: true + require_for_portal: true + +database: + url: "${APITEST_DB_URL:-postgres://api:api@localhost:5432/apitest?sslmode=disable}" + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h + +audit: + enabled: true + retention_days: 7 # api-test responses can be huge; keep window small + capture_payloads: true + capture_headers: true + max_payload_bytes: 1048576 # 1 MiB; oversize bodies write headers-only + +portal: + enabled: true + cookie_name: "api_test_session" + cookie_secret: "${APITEST_COOKIE_SECRET}" + cookie_secure: false + oidc_redirect_path: "/portal/auth/callback" + +endpoints: + identity: { enabled: true } + 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+ + +# Optional: POST a connection definition to a Plexara admin URL on boot +# so api-test self-registers in dev. Default off; keep fixture decoupled. +plexara: + register: + enabled: false + admin_url: "${PLEXARA_ADMIN_URL:-http://localhost:9000/api/v1/admin/api-gateway/connections}" + auth_header: "${PLEXARA_ADMIN_AUTH:-}" + connection_name: "api-test" diff --git a/configs/api-test.live.yaml b/configs/api-test.live.yaml new file mode 100644 index 0000000..1b65069 --- /dev/null +++ b/configs/api-test.live.yaml @@ -0,0 +1,62 @@ +# api-test live config: full auth + Postgres + Keycloak. Used by `make dev` +# alongside docker-compose.dev.yml. Secrets come from .env.dev (gitignored; +# generated on first run by `make dev-secrets`). +# +# Postgres lives at localhost:5432; Keycloak at localhost:8081 with the +# api-test realm pre-seeded by dev/keycloak/api-test-realm.json. + +server: + name: api-test + address: ":8080" + base_url: "http://localhost:8080" + +oidc: + enabled: true + issuer: "http://localhost:8081/realms/api-test" + audience: "api-test" + allowed_clients: ["api-test-portal", "plexara-cc", "plexara-ac"] + clock_skew_seconds: 30 + jwks_cache_ttl: 1h + +api_keys: + file: + - { name: "devkey", key: "${APITEST_DEV_KEY}", description: "dev key from .env.dev" } + db: + enabled: true + +bearer: + tokens: + - { name: "devbearer", token: "${APITEST_DEV_BEARER:-bearer-please-change}", description: "dev bearer from .env.dev" } + +auth: + allow_anonymous: false + require_for_api: true + require_for_portal: true + +database: + url: "postgres://api:api@localhost:5432/apitest?sslmode=disable" + +audit: + enabled: true + retention_days: 7 + capture_payloads: true + capture_headers: true + max_payload_bytes: 1048576 + +portal: + enabled: true + cookie_name: "api_test_session" + cookie_secret: "${APITEST_COOKIE_SECRET}" + cookie_secure: false + oidc_redirect_path: "/portal/auth/callback" + +endpoints: + identity: { enabled: true } + data: { enabled: true } + failure: { enabled: true } + echo: { enabled: true } + streaming: { enabled: false } + pagination: { enabled: false } + methods: { enabled: false } + security: { enabled: false } + export: { enabled: false } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5864b6c --- /dev/null +++ b/go.mod @@ -0,0 +1,69 @@ +module github.com/plexara/api-test + +go 1.26.3 + +require ( + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.9.2 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 + golang.org/x/crypto v0.51.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.1 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.26.3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5b6dfb7 --- /dev/null +++ b/go.sum @@ -0,0 +1,173 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4= +github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..91a5680 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,243 @@ +// Package server composes the HTTP server, endpoint registry, audit log, +// inbound auth chain, and lifecycle. +// +// M2 surface: full DB + audit + non-OAuth inbound auth (file/DB API keys +// + static bearer tokens). OIDC, the portal, and the SPA arrive in M3. +package server + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/plexara/api-test/pkg/apikeys" + "github.com/plexara/api-test/pkg/audit" + auditpg "github.com/plexara/api-test/pkg/audit/postgres" + "github.com/plexara/api-test/pkg/auth/inbound" + "github.com/plexara/api-test/pkg/build" + "github.com/plexara/api-test/pkg/config" + "github.com/plexara/api-test/pkg/database" + "github.com/plexara/api-test/pkg/database/migrate" + "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/failure" + "github.com/plexara/api-test/pkg/endpoints/identity" + "github.com/plexara/api-test/pkg/httpmw" + "github.com/plexara/api-test/pkg/httpsrv" +) + +// Application is the wired-up server, ready to be started with Run. +type Application struct { + cfg *config.Config + logger *slog.Logger + pool *pgxpool.Pool + registry *endpoints.Registry + auditLog audit.Logger + asyncAudit *audit.AsyncLogger + chain *inbound.Chain + dbKeys *apikeys.Store + readiness *httpsrv.Readiness + mux http.Handler +} + +// Build constructs an Application from a config. M2 wiring: +// - opens Postgres pool + runs migrations when database.url is set +// - constructs AsyncLogger over either the Postgres store or NoopLogger +// - composes the inbound auth chain (file keys + DB keys + bearer) +// - mounts the endpoint registry through the audit/identity middleware +func Build(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*Application, error) { + app := &Application{cfg: cfg, logger: logger} + + // --- Database (optional in M2) --- + if cfg.Database.URL != "" { + if err := migrate.Up(cfg.Database.URL); err != nil { + return nil, fmt.Errorf("migrations: %w", err) + } + pool, err := database.Open(ctx, cfg.Database) + if err != nil { + return nil, fmt.Errorf("database: %w", err) + } + app.pool = pool + } + + // --- Audit logger --- + app.auditLog = audit.NoopLogger{} + if cfg.Audit.Enabled { + if app.pool == nil { + return nil, errors.New("audit.enabled requires database.url") + } + app.asyncAudit = audit.NewAsyncLogger(auditpg.New(app.pool), 4096, 5*time.Second, logger) + app.auditLog = app.asyncAudit + } else { + logger.Info("audit disabled by config") + } + + // --- Inbound auth chain --- + fileStore := inbound.NewFileAPIKeyStore(cfg.APIKeys.File) + var keyStore inbound.APIKeyStore = fileStore + if cfg.APIKeys.DB.Enabled { + if app.pool == nil { + return nil, errors.New("api_keys.db.enabled requires database.url") + } + app.dbKeys = apikeys.New(app.pool) + keyStore = inbound.CombineAPIKeyStores(fileStore, app.dbKeys.AsInboundStore()) + } + apikeyAuth := inbound.NewAPIKey(keyStore, cfg.APIKeys.HeaderName, cfg.APIKeys.QueryParamName) + bearerAuth := inbound.NewBearer(cfg.Bearer.Tokens) + app.chain = inbound.NewChain(cfg.Auth.AllowAnonymous, apikeyAuth, bearerAuth) + + // --- Endpoint registry --- + app.registry = buildRegistry(cfg) + + // --- Middleware stack --- + identityMW := httpmw.Identity(app.chain, logger) + auditMW := httpmw.Audit(app.auditLog, app.registry, logger, httpmw.AuditOptions{ + CapturePayloads: cfg.Audit.CapturePayloadsEnabled() && cfg.Audit.Enabled, + CaptureHeaders: cfg.Audit.CaptureHeadersEnabled(), + MaxPayloadBytes: cfg.Audit.MaxPayloadBytes, + RedactKeys: cfg.Audit.RedactKeys, + }) + endpointMW := func(next http.Handler) http.Handler { + return identityMW(auditMW(next)) + } + + app.readiness = httpsrv.NewReadiness() + core := httpsrv.BuildMux(app.registry, app.readiness, endpointMW) + // AccessLog + RequestID wrap the entire mux so health probes also get + // request ids; identity/audit only run on endpoint group routes (via + // endpointMW above). + app.mux = httpmw.RequestID(httpmw.AccessLog(logger)(core)) + return app, nil +} + +// BuildWithDeps assembles an Application from supplied dependencies, +// skipping database setup. Used by tests that inject in-memory loggers +// and stub auth. +func BuildWithDeps(cfg *config.Config, logger *slog.Logger, chain *inbound.Chain, auditLog audit.Logger) *Application { + if auditLog == nil { + auditLog = audit.NoopLogger{} + } + if chain == nil { + chain = inbound.NewChain(true) + } + registry := buildRegistry(cfg) + identityMW := httpmw.Identity(chain, logger) + auditMW := httpmw.Audit(auditLog, registry, logger, httpmw.AuditOptions{ + CapturePayloads: cfg.Audit.CapturePayloadsEnabled(), + CaptureHeaders: cfg.Audit.CaptureHeadersEnabled(), + MaxPayloadBytes: cfg.Audit.MaxPayloadBytes, + RedactKeys: cfg.Audit.RedactKeys, + }) + endpointMW := func(next http.Handler) http.Handler { + return identityMW(auditMW(next)) + } + readiness := httpsrv.NewReadiness() + core := httpsrv.BuildMux(registry, readiness, endpointMW) + mux := httpmw.RequestID(httpmw.AccessLog(logger)(core)) + return &Application{ + cfg: cfg, + logger: logger, + registry: registry, + auditLog: auditLog, + chain: chain, + readiness: readiness, + mux: mux, + } +} + +// buildRegistry wires up the endpoint groups enabled by config. +func buildRegistry(cfg *config.Config) *endpoints.Registry { + r := endpoints.NewRegistry() + if cfg.Endpoints.Identity.Enabled { + r.Add(identity.New(cfg.Audit.RedactKeys)) + } + if cfg.Endpoints.Data.Enabled { + r.Add(data.New()) + } + if cfg.Endpoints.Failure.Enabled { + r.Add(failure.New()) + } + if cfg.Endpoints.Echo.Enabled { + r.Add(echo.New(cfg.Audit.RedactKeys)) + } + return r +} + +// Close releases held resources: drains the async audit queue, then +// closes the database pool. +func (a *Application) Close() { + if a.asyncAudit != nil { + a.asyncAudit.Close() + } + if a.pool != nil { + a.pool.Close() + } +} + +// Handler returns the wrapped HTTP handler. +func (a *Application) Handler() http.Handler { return a.mux } + +// Registry exposes the endpoint registry. +func (a *Application) Registry() *endpoints.Registry { return a.registry } + +// AuditLog exposes the audit logger so tests can assert on captured events. +func (a *Application) AuditLog() audit.Logger { return a.auditLog } + +// Run blocks listening on cfg.Server.Address until ctx is cancelled. +// Graceful drain identical to mcp-test. +func (a *Application) Run(ctx context.Context) error { + srv := &http.Server{ + Addr: a.cfg.Server.Address, + Handler: a.mux, + ReadHeaderTimeout: a.cfg.Server.ReadHeaderTimeout, + } + errCh := make(chan error, 1) + go func() { + a.logger.Info("listening", "address", a.cfg.Server.Address) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + close(errCh) + }() + + select { + case err := <-errCh: + return err + case <-ctx.Done(): + } + + a.logger.Info("shutdown requested, draining") + a.readiness.SetReady(false) + + if d := a.cfg.Server.Shutdown.PreShutdownDelay; d > 0 { + select { + case <-time.After(d): + case err := <-errCh: + a.logger.Warn("listener exited during pre-shutdown delay", "err", err) + case <-ctx.Done(): + } + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), a.cfg.Server.Shutdown.GracePeriod) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("shutdown: %w", err) + } + if err, ok := <-errCh; ok && err != nil { + a.logger.Warn("listener post-shutdown error", "err", err) + } + a.logger.Info("shutdown complete") + return nil +} + +// Version returns the build metadata as a one-line string. +func Version() string { + return strings.Join([]string{build.Version, build.Commit, build.Date}, " ") +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..197892e --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,277 @@ +package server + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/plexara/api-test/pkg/audit" + "github.com/plexara/api-test/pkg/auth/inbound" + "github.com/plexara/api-test/pkg/config" +) + +func newTestApp(t *testing.T) *Application { + t.Helper() + cfg := &config.Config{ + Auth: config.AuthConfig{AllowAnonymous: true}, + Endpoints: config.EndpointsConfig{ + Identity: config.EndpointGroupConfig{Enabled: true}, + Data: config.EndpointGroupConfig{Enabled: true}, + Failure: config.EndpointGroupConfig{Enabled: true}, + Echo: config.EndpointGroupConfig{Enabled: true}, + }, + } + // Apply defaults to keep ServeAddress non-empty etc. + (&applyDefaultsConfig{cfg}).do() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + app, err := Build(context.Background(), cfg, logger) + if err != nil { + t.Fatalf("build: %v", err) + } + t.Cleanup(app.Close) + return app +} + +// applyDefaultsConfig is a tiny adapter so tests can call applyDefaults +// without importing the unexported method directly. We round-trip through +// the YAML loader instead by writing a temp file in cases that need it. +type applyDefaultsConfig struct{ cfg *config.Config } + +func (a *applyDefaultsConfig) do() { + // Load() applies defaults and validates. We round-trip a minimal YAML + // document that mirrors the in-memory fields we set above. + if a.cfg.Server.Address == "" { + a.cfg.Server.Address = ":0" + } +} + +func TestBuild_AllGroupsRegistered(t *testing.T) { + app := newTestApp(t) + if len(app.Registry().Groups()) != 4 { + t.Errorf("groups = %d want 4", len(app.Registry().Groups())) + } +} + +func TestBuild_ServesWhoami(t *testing.T) { + app := newTestApp(t) + srv := httptest.NewServer(app.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/whoami") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), `"auth_type":"anonymous"`) { + t.Errorf("unexpected body: %s", body) + } +} + +func TestBuild_ServesEcho(t *testing.T) { + app := newTestApp(t) + srv := httptest.NewServer(app.Handler()) + defer srv.Close() + + resp, err := http.Post(srv.URL+"/v1/echo", "application/json", strings.NewReader(`{"k":1}`)) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + var out map[string]any + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + if out["method"] != "POST" { + t.Errorf("method = %v", out["method"]) + } +} + +func TestBuild_NoGroups(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{AllowAnonymous: true}, + Endpoints: config.EndpointsConfig{}, + } + cfg.Server.Address = ":0" + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + app, err := Build(context.Background(), cfg, logger) + if err != nil { + t.Fatalf("build: %v", err) + } + defer app.Close() + if got := len(app.Registry().Groups()); got != 0 { + t.Errorf("groups = %d want 0", got) + } + // Health still works. + srv := httptest.NewServer(app.Handler()) + defer srv.Close() + resp, err := http.Get(srv.URL + "/healthz") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("healthz status %d", resp.StatusCode) + } +} + +func TestVersion(t *testing.T) { + if v := Version(); v == "" { + t.Error("Version() empty") + } +} + +func TestBuildWithDeps_APIKeyAuth_WhoamiAndAudit(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{AllowAnonymous: false}, + Endpoints: config.EndpointsConfig{ + Identity: config.EndpointGroupConfig{Enabled: true}, + }, + APIKeys: config.APIKeysConfig{ + HeaderName: "X-API-Key", + QueryParamName: "api_key", + }, + } + cfg.Server.Address = ":0" + + store := inbound.NewFileAPIKeyStore([]config.FileAPIKey{{Name: "devkey", Key: "secret"}}) + chain := inbound.NewChain(false, + inbound.NewAPIKey(store, "X-API-Key", "api_key"), + ) + ml := audit.NewMemoryLogger() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + app := BuildWithDeps(cfg, logger, chain, ml) + defer app.Close() + + srv := httptest.NewServer(app.Handler()) + defer srv.Close() + + t.Run("missing key returns 401", func(t *testing.T) { + resp, err := http.Get(srv.URL + "/v1/whoami") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("status %d", resp.StatusCode) + } + if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Bearer") { + t.Errorf("WWW-Authenticate missing") + } + }) + + t.Run("valid key reports identity", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/whoami", nil) + req.Header.Set("X-API-Key", "secret") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d", resp.StatusCode) + } + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["auth_type"] != "apikey" { + t.Errorf("auth_type = %v", body["auth_type"]) + } + if body["subject"] != "devkey" { + t.Errorf("subject = %v", body["subject"]) + } + }) + + t.Run("audit captured the request", func(t *testing.T) { + // Force a fresh known-key request so the audit row is deterministic. + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/whoami", nil) + req.Header.Set("X-API-Key", "secret") + req.Header.Set("X-Trace-Id", "audit-test-1") + resp, _ := http.DefaultClient.Do(req) + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + // Find the audit row for this request. + evs := ml.Snapshot() + if len(evs) == 0 { + t.Fatal("no audit events captured") + } + var matched bool + for _, ev := range evs { + if ev.Method != "GET" || ev.Path != "/v1/whoami" { + continue + } + if ev.UserSubject != "devkey" || ev.AuthType != "apikey" { + continue + } + if ev.Status != http.StatusOK || !ev.Success { + continue + } + matched = true + break + } + if !matched { + t.Errorf("no matching audit event found in %d events", len(evs)) + } + }) + + t.Run("query-param API key works", func(t *testing.T) { + resp, err := http.Get(srv.URL + "/v1/whoami?api_key=secret") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Errorf("status %d", resp.StatusCode) + } + }) +} + +func TestRun_GracefulShutdown(t *testing.T) { + cfg := &config.Config{ + Auth: config.AuthConfig{AllowAnonymous: true}, + Endpoints: config.EndpointsConfig{ + Identity: config.EndpointGroupConfig{Enabled: true}, + }, + } + cfg.Server.Address = "127.0.0.1:0" // OS-assigned port; we don't actually probe + // Tight shutdown timings so the test finishes in <1s. + cfg.Server.Shutdown.GracePeriod = 500 * 1_000_000 // 500ms in ns + cfg.Server.Shutdown.PreShutdownDelay = 10 * 1_000_000 // 10ms in ns + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + app, err := Build(context.Background(), cfg, logger) + if err != nil { + t.Fatalf("build: %v", err) + } + defer app.Close() + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { errCh <- app.Run(ctx) }() + + // Cancel almost immediately; Run() should drain and exit cleanly. + cancel() + select { + case err := <-errCh: + if err != nil { + t.Errorf("Run returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after context cancel") + } +} diff --git a/internal/ui/dist/.gitkeep b/internal/ui/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/internal/ui/embed.go b/internal/ui/embed.go new file mode 100644 index 0000000..7a4602d --- /dev/null +++ b/internal/ui/embed.go @@ -0,0 +1,45 @@ +// Package ui embeds the compiled React SPA so it ships inside the binary. +// +// In M1 the dist/ directory contains only a .gitkeep placeholder; the SPA +// lands in M3 (`make ui` builds and copies it into dist/). Until then, +// Available() returns false and callers should fall back to a JSON banner +// or static HTML stub. +package ui + +import ( + "embed" + "errors" + "io/fs" +) + +//go:embed all:dist +var distFS embed.FS + +// Available reports whether a real SPA was built into dist/. It returns +// false when only .gitkeep is present so the composition layer can avoid +// mounting an empty SPA. +func Available() bool { + entries, err := distFS.ReadDir("dist") + if err != nil { + return false + } + for _, e := range entries { + if e.Name() == "index.html" { + return true + } + } + return false +} + +// FS returns the dist subtree rooted at "dist". Returns an error when the +// embed is empty (no SPA built); callers should check Available() first. +func FS() (fs.FS, error) { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + return nil, err + } + if !Available() { + return nil, errors.New("ui: dist/ has no index.html (run `make ui` to build the SPA)") + } + return sub, nil +} diff --git a/internal/ui/embed_test.go b/internal/ui/embed_test.go new file mode 100644 index 0000000..ebd8d96 --- /dev/null +++ b/internal/ui/embed_test.go @@ -0,0 +1,16 @@ +package ui + +import "testing" + +func TestAvailable_NoSPA(t *testing.T) { + // In M1 dist/ only contains .gitkeep, so Available() returns false. + if Available() { + t.Error("Available() = true, want false (no SPA built yet)") + } +} + +func TestFS_ErrorsWhenEmpty(t *testing.T) { + if _, err := FS(); err == nil { + t.Error("FS() returned nil error with empty dist/") + } +} diff --git a/pkg/apikeys/inbound.go b/pkg/apikeys/inbound.go new file mode 100644 index 0000000..a4d95aa --- /dev/null +++ b/pkg/apikeys/inbound.go @@ -0,0 +1,26 @@ +package apikeys + +import ( + "context" + "errors" + + "github.com/plexara/api-test/pkg/auth/inbound" +) + +// AsInboundStore adapts the bcrypt-backed Store to the inbound.APIKeyStore +// interface so the inbound auth chain can layer it under the file-backed +// store. +func (s *Store) AsInboundStore() inbound.APIKeyStore { return inboundAdapter{s: s} } + +type inboundAdapter struct{ s *Store } + +func (a inboundAdapter) LookupAPIKey(ctx context.Context, plaintext string) (string, error) { + k, err := a.s.Authenticate(ctx, plaintext) + if err != nil { + if errors.Is(err, ErrNotFound) { + return "", inbound.ErrInvalidCredential + } + return "", err + } + return k.Name, nil +} diff --git a/pkg/apikeys/store.go b/pkg/apikeys/store.go new file mode 100644 index 0000000..8a3e383 --- /dev/null +++ b/pkg/apikeys/store.go @@ -0,0 +1,185 @@ +// Package apikeys persists API keys in Postgres with bcrypt-hashed values. +// +// Used by the inbound auth chain (pkg/auth/inbound) when api_keys.db.enabled +// is true. File-based keys live in pkg/auth/inbound directly; this package +// is only needed for keys that operators rotate via the portal. +package apikeys + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +// ErrNotFound is returned when an API key with the given name doesn't exist. +var ErrNotFound = errors.New("api key not found") + +// Key is the persistent record. Hash is never returned by List (omitted from +// JSON), and the plaintext value only exists at creation time. +type Key struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedBy string `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` +} + +// Created bundles a freshly minted key with its plaintext value. The +// plaintext is shown to the user once and never persisted. +type Created struct { + Key Key `json:"key"` + Plaintext string `json:"plaintext"` +} + +// Store is a pgxpool-backed CRUD interface over the api_keys table. +type Store struct { + pool *pgxpool.Pool +} + +// New returns a Store. +func New(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Create mints a new API key with a random plaintext value, hashes it +// with bcrypt, and inserts a row. The plaintext is returned to the +// caller exactly once. +// +// `name` must be unique. If a row with the same name exists, the call +// returns an error. +func (s *Store) Create(ctx context.Context, name, description, createdBy string, expiresAt *time.Time) (*Created, error) { + if name == "" { + return nil, errors.New("name is required") + } + plaintext, err := generatePlaintext() + if err != nil { + return nil, err + } + hash, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("hash: %w", err) + } + id := uuid.NewString() + now := time.Now().UTC() + + _, err = s.pool.Exec(ctx, ` + INSERT INTO api_keys (id, name, hash, description, created_by, created_at, expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7) + `, id, name, string(hash), description, createdBy, now, expiresAt) + if err != nil { + return nil, fmt.Errorf("insert api key: %w", err) + } + + return &Created{ + Key: Key{ + ID: id, + Name: name, + Description: description, + CreatedBy: createdBy, + CreatedAt: now, + ExpiresAt: expiresAt, + }, + Plaintext: plaintext, + }, nil +} + +// List returns every key (without the hash). Sorted by created_at DESC. +func (s *Store) List(ctx context.Context) ([]Key, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, name, COALESCE(description,''), COALESCE(created_by,''), + created_at, expires_at, last_used_at + FROM api_keys + ORDER BY created_at DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []Key + for rows.Next() { + var k Key + if err := rows.Scan(&k.ID, &k.Name, &k.Description, &k.CreatedBy, + &k.CreatedAt, &k.ExpiresAt, &k.LastUsedAt); err != nil { + return nil, err + } + out = append(out, k) + } + return out, rows.Err() +} + +// Delete removes the row with the given name. Returns ErrNotFound if absent. +func (s *Store) Delete(ctx context.Context, name string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM api_keys WHERE name = $1`, name) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// Authenticate scans every (non-expired) row, comparing the candidate +// against each stored bcrypt hash. Bcrypt is intentionally slow so this +// scales poorly past ~thousands of keys; fine for a test fixture. +// +// On match, last_used_at is bumped (best-effort). +func (s *Store) Authenticate(ctx context.Context, plaintext string) (*Key, error) { + if plaintext == "" { + return nil, ErrNotFound + } + rows, err := s.pool.Query(ctx, ` + SELECT id, name, hash, COALESCE(description,''), COALESCE(created_by,''), + created_at, expires_at, last_used_at + FROM api_keys + WHERE expires_at IS NULL OR expires_at > now() + `) + if err != nil { + return nil, err + } + defer rows.Close() + + pw := []byte(plaintext) + for rows.Next() { + var k Key + var hash string + if err := rows.Scan(&k.ID, &k.Name, &hash, &k.Description, &k.CreatedBy, + &k.CreatedAt, &k.ExpiresAt, &k.LastUsedAt); err != nil { + return nil, err + } + if bcrypt.CompareHashAndPassword([]byte(hash), pw) == nil { + s.touchLastUsed(ctx, k.ID) + return &k, nil + } + } + if err := rows.Err(); err != nil { + return nil, err + } + return nil, ErrNotFound +} + +func (s *Store) touchLastUsed(ctx context.Context, id string) { + // Best-effort; ignore errors; auth succeeded regardless. + _, _ = s.pool.Exec(ctx, `UPDATE api_keys SET last_used_at = now() WHERE id = $1`, id) +} + +// generatePlaintext returns a URL-safe random 32-byte token prefixed for +// recognizability ("at_" = api-test). +func generatePlaintext() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return "at_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buf), "="), nil +} diff --git a/pkg/audit/async.go b/pkg/audit/async.go new file mode 100644 index 0000000..03a1798 --- /dev/null +++ b/pkg/audit/async.go @@ -0,0 +1,145 @@ +package audit + +import ( + "context" + "log/slog" + "sync" + "time" +) + +// AsyncLogger wraps a Logger and writes events through a buffered channel +// drained by a background worker. The synchronous request path enqueues +// in O(1); the goroutine handles the actual database write. +// +// On a full buffer the event is dropped (and counted) so the audit +// pipeline can never block a request. Operators preferring lossless audit +// must size the buffer for their peak rate. +type AsyncLogger struct { + inner Logger + logger *slog.Logger + ch chan Event + wg sync.WaitGroup + timeout time.Duration + stop chan struct{} + stopOnce sync.Once + + mu sync.Mutex + dropped uint64 +} + +// NewAsyncLogger returns a buffered async wrapper around inner. +// +// bufferSize is the channel depth; perCallTimeout bounds each underlying +// Log call. Call Close() during shutdown to drain the queue. +func NewAsyncLogger(inner Logger, bufferSize int, perCallTimeout time.Duration, logger *slog.Logger) *AsyncLogger { + if bufferSize <= 0 { + bufferSize = 1024 + } + if perCallTimeout <= 0 { + perCallTimeout = 5 * time.Second + } + if logger == nil { + logger = slog.Default() + } + a := &AsyncLogger{ + inner: inner, + logger: logger, + ch: make(chan Event, bufferSize), + timeout: perCallTimeout, + stop: make(chan struct{}), + } + a.wg.Add(1) + go a.run() + return a +} + +// Log enqueues; non-blocking. Returns nil even when the buffer is full so +// the request path is never gated on the audit pipeline. +func (a *AsyncLogger) Log(_ context.Context, ev Event) error { + select { + case a.ch <- ev: + default: + a.mu.Lock() + a.dropped++ + dropped := a.dropped + a.mu.Unlock() + if dropped%1000 == 1 { + a.logger.Warn("audit buffer full; dropping events", "dropped_total", dropped) + } + } + return nil +} + +// Query delegates to the inner Logger; reads don't need buffering. +func (a *AsyncLogger) Query(ctx context.Context, f QueryFilter) ([]Event, error) { + return a.inner.Query(ctx, f) +} + +// Count delegates to the inner Logger. +func (a *AsyncLogger) Count(ctx context.Context, f QueryFilter) (int64, error) { + return a.inner.Count(ctx, f) +} + +// GetPayload delegates to the inner Logger when it implements +// PayloadLogger. Returns (nil, nil) when the underlying logger doesn't +// persist payloads (memory, noop). +func (a *AsyncLogger) GetPayload(ctx context.Context, eventID string) (*Payload, error) { + pl, ok := a.inner.(PayloadLogger) + if !ok { + return nil, nil + } + return pl.GetPayload(ctx, eventID) +} + +// Close stops accepting new events and waits for the queue to drain. +func (a *AsyncLogger) Close() { + a.stopOnce.Do(func() { close(a.stop) }) + a.wg.Wait() +} + +// Dropped reports the cumulative drop count for monitoring. +func (a *AsyncLogger) Dropped() uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return a.dropped +} + +func (a *AsyncLogger) run() { + defer a.wg.Done() + for { + select { + case ev := <-a.ch: + a.write(ev) + case <-a.stop: + // Drain remaining events on shutdown. + for { + select { + case ev := <-a.ch: + a.write(ev) + default: + return + } + } + } + } +} + +func (a *AsyncLogger) write(ev Event) { + ctx, cancel := context.WithTimeout(context.Background(), a.timeout) + defer cancel() + if err := a.inner.Log(ctx, ev); err != nil { + a.logger.Warn("audit write failed", "method", ev.Method, "path", ev.Path, "err", err) + } +} + +// NoopLogger is a Logger that drops everything. Used when audit.enabled=false. +type NoopLogger struct{} + +// Log discards the event. +func (NoopLogger) Log(context.Context, Event) error { return nil } + +// Query returns no events. +func (NoopLogger) Query(context.Context, QueryFilter) ([]Event, error) { return nil, nil } + +// Count returns 0. +func (NoopLogger) Count(context.Context, QueryFilter) (int64, error) { return 0, nil } diff --git a/pkg/audit/async_test.go b/pkg/audit/async_test.go new file mode 100644 index 0000000..2eff5f5 --- /dev/null +++ b/pkg/audit/async_test.go @@ -0,0 +1,109 @@ +package audit + +import ( + "context" + "io" + "log/slog" + "sync/atomic" + "testing" + "time" +) + +type countingLogger struct { + logs atomic.Int32 +} + +func (c *countingLogger) Log(_ context.Context, _ Event) error { + c.logs.Add(1) + return nil +} +func (c *countingLogger) Query(context.Context, QueryFilter) ([]Event, error) { return nil, nil } +func (c *countingLogger) Count(context.Context, QueryFilter) (int64, error) { return 0, nil } + +func TestAsyncLogger_DrainsOnClose(t *testing.T) { + cl := &countingLogger{} + a := NewAsyncLogger(cl, 64, time.Second, slog.New(slog.NewTextHandler(io.Discard, nil))) + for i := 0; i < 10; i++ { + _ = a.Log(context.Background(), Event{Method: "GET", Path: "/x"}) + } + a.Close() + if got := cl.logs.Load(); got != 10 { + t.Errorf("inner Log called %d times, want 10", got) + } + if a.Dropped() != 0 { + t.Errorf("Dropped() = %d, want 0", a.Dropped()) + } +} + +func TestAsyncLogger_DropsOnFullBuffer(t *testing.T) { + // blocked logger never returns from Log, holding the drain goroutine. + bl := &blockingLogger{started: make(chan struct{})} + a := NewAsyncLogger(bl, 1, time.Second, slog.New(slog.NewTextHandler(io.Discard, nil))) + defer a.Close() + + // First call enqueues immediately (buffer has 1 slot); the drain + // picks it up and blocks inside the inner Log. + _ = a.Log(context.Background(), Event{Method: "GET", Path: "/1"}) + <-bl.started + + // Second call enqueues into the now-empty buffer. + _ = a.Log(context.Background(), Event{Method: "GET", Path: "/2"}) + + // Third call must drop because buffer is full and drain is still blocked. + _ = a.Log(context.Background(), Event{Method: "GET", Path: "/3"}) + + if a.Dropped() == 0 { + t.Error("expected at least 1 drop, got 0") + } +} + +func TestAsyncLogger_DelegatesQueryAndCount(t *testing.T) { + ml := NewMemoryLogger() + a := NewAsyncLogger(ml, 64, time.Second, slog.New(slog.NewTextHandler(io.Discard, nil))) + defer a.Close() + + _ = a.Log(context.Background(), Event{Method: "GET", Path: "/a", Status: 200, Success: true}) + // Wait briefly for the drain. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if c, _ := a.Count(context.Background(), QueryFilter{}); c == 1 { + break + } + time.Sleep(2 * time.Millisecond) + } + if c, _ := a.Count(context.Background(), QueryFilter{}); c != 1 { + t.Errorf("inner count = %d, want 1", c) + } + if evs, _ := a.Query(context.Background(), QueryFilter{}); len(evs) != 1 { + t.Errorf("inner query returned %d, want 1", len(evs)) + } +} + +func TestNoopLogger(t *testing.T) { + n := NoopLogger{} + if err := n.Log(context.Background(), Event{}); err != nil { + t.Errorf("Log err: %v", err) + } + if evs, _ := n.Query(context.Background(), QueryFilter{}); evs != nil { + t.Errorf("Query returned %v", evs) + } + if c, _ := n.Count(context.Background(), QueryFilter{}); c != 0 { + t.Errorf("Count = %d", c) + } +} + +type blockingLogger struct { + started chan struct{} + once bool +} + +func (b *blockingLogger) Log(ctx context.Context, _ Event) error { + if !b.once { + b.once = true + close(b.started) + } + <-ctx.Done() + return ctx.Err() +} +func (b *blockingLogger) Query(context.Context, QueryFilter) ([]Event, error) { return nil, nil } +func (b *blockingLogger) Count(context.Context, QueryFilter) (int64, error) { return 0, nil } diff --git a/pkg/audit/event.go b/pkg/audit/event.go new file mode 100644 index 0000000..0cf6193 --- /dev/null +++ b/pkg/audit/event.go @@ -0,0 +1,144 @@ +// Package audit defines the audit event shape and the Logger interface for +// api-test's HTTP request/response audit log. +// +// Event captures the indexable summary of one inbound HTTP request; the +// sibling Payload struct (joined 1:1 by ID) carries the full request and +// response envelope (headers, body, query). The two-table layout keeps the +// summary row small for time-range queries while letting operators drill +// into the full envelope on demand from the portal. +package audit + +import ( + "net/http" + "strings" + "time" +) + +// Event is the indexable summary written to audit_events. +type Event struct { + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + DurationMS int64 `json:"duration_ms"` + RequestID string `json:"request_id,omitempty"` + SessionID string `json:"session_id,omitempty"` + UserSubject string `json:"user_subject,omitempty"` + UserEmail string `json:"user_email,omitempty"` + AuthType string `json:"auth_type,omitempty"` + APIKeyName string `json:"api_key_name,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + RouteName string `json:"route_name,omitempty"` + EndpointGroup string `json:"endpoint_group,omitempty"` + Status int `json:"status"` + BytesIn int `json:"bytes_in"` + BytesOut int `json:"bytes_out"` + Success bool `json:"success"` + ErrorMessage string `json:"error_message,omitempty"` + ErrorCategory string `json:"error_category,omitempty"` + RemoteAddr string `json:"remote_addr,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + + // Payload, when non-nil, is the full request/response envelope. Written + // to audit_payloads in the same transaction as the summary. Nil means + // "no detail captured" (capture disabled, or the event predates capture). + Payload *Payload `json:"payload,omitempty"` +} + +// Payload is the full HTTP request/response envelope joined 1:1 with an +// Event by ID. Each side carries a byte size and a truncation flag so +// operators can tell whether they're looking at the whole body or a +// capped prefix. +type Payload struct { + // Request side + RequestHeaders map[string][]string `json:"request_headers,omitempty"` + RequestQuery map[string][]string `json:"request_query,omitempty"` + RequestContentType string `json:"request_content_type,omitempty"` + RequestBody []byte `json:"request_body,omitempty"` + RequestSizeBytes int `json:"request_size_bytes,omitempty"` + RequestTruncated bool `json:"request_truncated,omitempty"` + RequestRemoteAddr string `json:"request_remote_addr,omitempty"` + + // Response side + ResponseHeaders map[string][]string `json:"response_headers,omitempty"` + ResponseContentType string `json:"response_content_type,omitempty"` + ResponseBody []byte `json:"response_body,omitempty"` + ResponseSizeBytes int `json:"response_size_bytes,omitempty"` + ResponseTruncated bool `json:"response_truncated,omitempty"` + + // ReplayedFrom links a replayed call back to the original event's ID. + // Set by the portal replay endpoint (M3+). + ReplayedFrom string `json:"replayed_from,omitempty"` +} + +// NewEvent constructs an Event with sensible defaults filled in. +func NewEvent(method, path string) *Event { + return &Event{ + Timestamp: time.Now().UTC(), + Method: method, + Path: path, + } +} + +// SanitizeHeaders returns a deep copy of h with values for any header whose +// name contains a redact substring (case-insensitive) replaced by +// "[redacted]". Used by the audit middleware so the persisted payload row +// never carries Authorization or X-API-Key in plaintext. +// +// Fast path: when redactKeys is empty, the input map is returned by +// reference. Callers needing a defensive copy should make one themselves. +func SanitizeHeaders(h http.Header, redactKeys []string) map[string][]string { + if len(redactKeys) == 0 { + // Convert to plain map[string][]string but share the underlying + // slices; the caller is responsible for not mutating header values. + out := make(map[string][]string, len(h)) + for k, v := range h { + out[k] = v + } + return out + } + out := make(map[string][]string, len(h)) + for k, v := range h { + if matchesRedactKey(k, redactKeys) { + out[k] = []string{"[redacted]"} + continue + } + // Copy slice so a downstream mutation of out can't propagate back + // into the request's Header map. + cp := make([]string, len(v)) + copy(cp, v) + out[k] = cp + } + return out +} + +// SanitizeQuery returns a deep copy of q with values redacted by the same +// rule SanitizeHeaders uses. Used for ?api_key=... and similar. +func SanitizeQuery(q map[string][]string, redactKeys []string) map[string][]string { + if len(redactKeys) == 0 { + return q + } + out := make(map[string][]string, len(q)) + for k, v := range q { + if matchesRedactKey(k, redactKeys) { + out[k] = []string{"[redacted]"} + continue + } + cp := make([]string, len(v)) + copy(cp, v) + out[k] = cp + } + return out +} + +func matchesRedactKey(key string, redactKeys []string) bool { + lk := strings.ToLower(key) + for _, rk := range redactKeys { + if rk == "" { + continue + } + if strings.Contains(lk, strings.ToLower(rk)) { + return true + } + } + return false +} diff --git a/pkg/audit/event_test.go b/pkg/audit/event_test.go new file mode 100644 index 0000000..9684d52 --- /dev/null +++ b/pkg/audit/event_test.go @@ -0,0 +1,72 @@ +package audit + +import ( + "net/http" + "testing" +) + +func TestSanitizeHeaders_Redacts(t *testing.T) { + h := http.Header{} + h.Set("Authorization", "Bearer secret") + h.Set("X-API-Key", "key-abc") + h.Set("Cookie", "session=xyz") + h.Set("X-Trace-Id", "trace-1") + h.Add("Accept-Language", "en") + h.Add("Accept-Language", "fr") + + // "api-key" needed because "X-API-Key" lowercases to "x-api-key" (dashes, + // not underscores). The default redact list in pkg/config includes both. + out := SanitizeHeaders(h, []string{"authorization", "api-key", "cookie"}) + + for _, name := range []string{"Authorization", "X-Api-Key", "Cookie"} { + v, ok := out[name] + if !ok { + t.Errorf("missing header %q", name) + continue + } + if len(v) != 1 || v[0] != "[redacted]" { + t.Errorf("%s not redacted: %v", name, v) + } + } + if v := out["X-Trace-Id"]; len(v) != 1 || v[0] != "trace-1" { + t.Errorf("non-secret header altered: %v", v) + } + if v := out["Accept-Language"]; len(v) != 2 { + t.Errorf("multi-value header lost values: %v", v) + } +} + +func TestSanitizeHeaders_EmptyKeysReturnsCopy(t *testing.T) { + h := http.Header{"X": {"y"}} + out := SanitizeHeaders(h, nil) + if got := out["X"]; len(got) != 1 || got[0] != "y" { + t.Errorf("got %v", got) + } +} + +func TestSanitizeQuery_Redacts(t *testing.T) { + q := map[string][]string{ + "api_key": {"k123"}, + "foo": {"bar"}, + } + out := SanitizeQuery(q, []string{"api_key"}) + if v := out["api_key"]; len(v) != 1 || v[0] != "[redacted]" { + t.Errorf("api_key not redacted: %v", v) + } + if v := out["foo"]; len(v) != 1 || v[0] != "bar" { + t.Errorf("foo altered: %v", v) + } +} + +func TestNewEvent(t *testing.T) { + ev := NewEvent("GET", "/v1/foo") + if ev.Method != "GET" { + t.Errorf("method = %q", ev.Method) + } + if ev.Path != "/v1/foo" { + t.Errorf("path = %q", ev.Path) + } + if ev.Timestamp.IsZero() { + t.Error("timestamp not set") + } +} diff --git a/pkg/audit/logger.go b/pkg/audit/logger.go new file mode 100644 index 0000000..978513c --- /dev/null +++ b/pkg/audit/logger.go @@ -0,0 +1,53 @@ +package audit + +import ( + "context" + "time" +) + +// Logger writes events and queries them back for the portal. Loggers that +// capture the audit_payloads sibling row implement PayloadLogger for the +// detail-fetch path; basic implementations (memory, noop) only hold the +// indexable summary. +// +// M2 surface is intentionally narrow: Log + Query + Count cover the +// integration-test needs. M3 expands with TimeSeries/Breakdown/Stats for +// the portal dashboard, Subscribe for the SSE live tail, and Stream for +// the NDJSON export. +type Logger interface { + Log(ctx context.Context, ev Event) error + Query(ctx context.Context, f QueryFilter) ([]Event, error) + Count(ctx context.Context, f QueryFilter) (int64, error) +} + +// PayloadLogger is the optional capability for detail fetch. Stores that +// persist the audit_payloads sibling row implement it; consumers type- +// assert for it before calling GetPayload. +type PayloadLogger interface { + GetPayload(ctx context.Context, eventID string) (*Payload, error) +} + +// MaxQueryLimit is the largest LIMIT any backend will honor on a single +// SELECT. Larger values get silently reduced. +const MaxQueryLimit = 1000 + +// QueryFilter narrows audit_events results. Filters are AND-combined. +// +// M2 implements the time, route, status, user, session, and search +// fields. JSONFilters/HasKeys (payload-row introspection) land in M3. +type QueryFilter struct { + From time.Time + To time.Time + Method string + Path string + RouteName string + UserID string + SessionID string + EventID string // exact-match on audit_events.id (single-event fetch) + Status int // exact match on response status; 0 means "any" + Success *bool + Search string + Limit int + Offset int + OrderDesc bool +} diff --git a/pkg/audit/memory.go b/pkg/audit/memory.go new file mode 100644 index 0000000..7081562 --- /dev/null +++ b/pkg/audit/memory.go @@ -0,0 +1,157 @@ +package audit + +import ( + "context" + "sort" + "strings" + "sync" + + "github.com/google/uuid" +) + +// MemoryLogger is an in-memory Logger used by tests. Implements +// PayloadLogger so the in-memory test path mirrors the Postgres path's +// detail-fetch contract. +type MemoryLogger struct { + mu sync.Mutex + events []Event +} + +// NewMemoryLogger returns an empty logger. +func NewMemoryLogger() *MemoryLogger { return &MemoryLogger{} } + +// Log appends the event. Auto-assigns ev.ID when empty so test fixtures +// see a stable id without setting one explicitly (matches Postgres +// store's behavior). +func (m *MemoryLogger) Log(_ context.Context, ev Event) error { + if ev.ID == "" { + ev.ID = uuid.NewString() + } + m.mu.Lock() + m.events = append(m.events, ev) + m.mu.Unlock() + return nil +} + +// Query returns matching events. ts DESC, id ASC tiebreaker. +// +// Pagination semantics match the Postgres store: Offset is applied first +// (clamped to zero on negative input), then Limit (defaulting to 100, +// clamped to MaxQueryLimit). +func (m *MemoryLogger) Query(_ context.Context, f QueryFilter) ([]Event, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := m.matchAll(f) + + offset := f.Offset + if offset < 0 { + offset = 0 + } + if offset >= len(out) { + return nil, nil + } + out = out[offset:] + + limit := f.Limit + if limit <= 0 { + limit = 100 + } + if limit > MaxQueryLimit { + limit = MaxQueryLimit + } + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// Count returns the number of matching events. Ignores Limit/Offset. +func (m *MemoryLogger) Count(_ context.Context, f QueryFilter) (int64, error) { + m.mu.Lock() + defer m.mu.Unlock() + return int64(len(m.matchAll(f))), nil +} + +// GetPayload returns the in-memory event's Payload pointer. Mirrors the +// PayloadLogger contract used by the portal (M3+). +func (m *MemoryLogger) GetPayload(_ context.Context, eventID string) (*Payload, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, ev := range m.events { + if ev.ID == eventID { + return ev.Payload, nil + } + } + return nil, nil +} + +// Snapshot returns a copy of all events in insertion order, for assertions. +func (m *MemoryLogger) Snapshot() []Event { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]Event, len(m.events)) + copy(out, m.events) + return out +} + +// matchAll applies every QueryFilter predicate EXCEPT Limit/Offset. +// Caller must hold m.mu. +func (m *MemoryLogger) matchAll(f QueryFilter) []Event { + out := make([]Event, 0, len(m.events)) + for _, ev := range m.events { + if !matchesFilter(ev, f) { + continue + } + out = append(out, ev) + } + sort.Slice(out, func(i, j int) bool { + if !out[i].Timestamp.Equal(out[j].Timestamp) { + return out[i].Timestamp.After(out[j].Timestamp) + } + return out[i].ID < out[j].ID + }) + return out +} + +func matchesFilter(ev Event, f QueryFilter) bool { + if f.EventID != "" && ev.ID != f.EventID { + return false + } + if f.Method != "" && ev.Method != f.Method { + return false + } + if f.Path != "" && ev.Path != f.Path { + return false + } + if f.RouteName != "" && ev.RouteName != f.RouteName { + return false + } + if f.UserID != "" && ev.UserSubject != f.UserID { + return false + } + if f.SessionID != "" && ev.SessionID != f.SessionID { + return false + } + if f.Status != 0 && ev.Status != f.Status { + return false + } + if !f.From.IsZero() && ev.Timestamp.Before(f.From) { + return false + } + if !f.To.IsZero() && ev.Timestamp.After(f.To) { + return false + } + if f.Success != nil && ev.Success != *f.Success { + return false + } + if f.Search != "" { + // Mirrors the Postgres store's `path ILIKE %q% OR error_message + // ILIKE %q%` predicate (case-insensitive substring on either). + needle := strings.ToLower(f.Search) + if !strings.Contains(strings.ToLower(ev.Path), needle) && + !strings.Contains(strings.ToLower(ev.ErrorMessage), needle) { + return false + } + } + return true +} diff --git a/pkg/audit/memory_test.go b/pkg/audit/memory_test.go new file mode 100644 index 0000000..58ac679 --- /dev/null +++ b/pkg/audit/memory_test.go @@ -0,0 +1,173 @@ +package audit + +import ( + "context" + "testing" + "time" +) + +func TestMemoryLogger_LogAndQuery(t *testing.T) { + ml := NewMemoryLogger() + ctx := context.Background() + now := time.Now().UTC() + for i := 0; i < 5; i++ { + ev := Event{ + Timestamp: now.Add(time.Duration(i) * time.Second), + Method: "GET", + Path: "/v1/whoami", + Status: 200, + Success: true, + } + if err := ml.Log(ctx, ev); err != nil { + t.Fatal(err) + } + } + evs, err := ml.Query(ctx, QueryFilter{Method: "GET"}) + if err != nil { + t.Fatal(err) + } + if len(evs) != 5 { + t.Errorf("got %d events, want 5", len(evs)) + } + // Newest first. + if !evs[0].Timestamp.After(evs[4].Timestamp) { + t.Errorf("ordering: %v then %v", evs[0].Timestamp, evs[4].Timestamp) + } + for _, ev := range evs { + if ev.ID == "" { + t.Error("Log did not auto-assign ID") + } + } +} + +func TestMemoryLogger_Filters(t *testing.T) { + ml := NewMemoryLogger() + ctx := context.Background() + + for _, ev := range []Event{ + {Method: "GET", Path: "/a", Status: 200, Success: true, UserSubject: "alice"}, + {Method: "POST", Path: "/b", Status: 500, Success: false, UserSubject: "bob"}, + {Method: "GET", Path: "/a", Status: 200, Success: true, UserSubject: "bob"}, + } { + _ = ml.Log(ctx, ev) + } + + cnt, _ := ml.Count(ctx, QueryFilter{Method: "GET"}) + if cnt != 2 { + t.Errorf("GET count = %d want 2", cnt) + } + cnt, _ = ml.Count(ctx, QueryFilter{UserID: "bob"}) + if cnt != 2 { + t.Errorf("bob count = %d want 2", cnt) + } + cnt, _ = ml.Count(ctx, QueryFilter{Status: 500}) + if cnt != 1 { + t.Errorf("status=500 count = %d want 1", cnt) + } + tr := false + cnt, _ = ml.Count(ctx, QueryFilter{Success: &tr}) + if cnt != 1 { + t.Errorf("success=false count = %d want 1", cnt) + } +} + +func TestMemoryLogger_GetPayload(t *testing.T) { + ml := NewMemoryLogger() + ctx := context.Background() + ev := Event{ + Method: "GET", + Path: "/v1/x", + Status: 200, + Payload: &Payload{RequestContentType: "application/json"}, + } + if err := ml.Log(ctx, ev); err != nil { + t.Fatal(err) + } + stored := ml.Snapshot() + id := stored[0].ID + got, err := ml.GetPayload(ctx, id) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("payload nil") + } + if got.RequestContentType != "application/json" { + t.Errorf("content type = %q", got.RequestContentType) + } + miss, err := ml.GetPayload(ctx, "no-such-id") + if err != nil { + t.Fatal(err) + } + if miss != nil { + t.Errorf("expected nil for missing id, got %+v", miss) + } +} + +func TestMemoryLogger_SearchAndOffset(t *testing.T) { + ml := NewMemoryLogger() + ctx := context.Background() + for _, ev := range []Event{ + {Method: "GET", Path: "/v1/whoami", Status: 200, Success: true}, + {Method: "GET", Path: "/v1/sized", Status: 200, Success: true}, + {Method: "GET", Path: "/v1/whoami", Status: 401, Success: false, ErrorMessage: "missing credential"}, + {Method: "GET", Path: "/v1/status/500", Status: 500, Success: false}, + } { + _ = ml.Log(ctx, ev) + } + + // Search matches path substring (case-insensitive). + cnt, _ := ml.Count(ctx, QueryFilter{Search: "whoami"}) + if cnt != 2 { + t.Errorf("search=whoami count = %d, want 2", cnt) + } + cnt, _ = ml.Count(ctx, QueryFilter{Search: "WHOAMI"}) + if cnt != 2 { + t.Errorf("search case-insensitive: %d want 2", cnt) + } + // Search also matches error_message. + cnt, _ = ml.Count(ctx, QueryFilter{Search: "missing"}) + if cnt != 1 { + t.Errorf("search=missing (error_message) count = %d, want 1", cnt) + } + + // Offset skips matching rows. + all, _ := ml.Query(ctx, QueryFilter{}) + if len(all) != 4 { + t.Fatalf("baseline events = %d, want 4", len(all)) + } + page1, _ := ml.Query(ctx, QueryFilter{Limit: 2, Offset: 0}) + page2, _ := ml.Query(ctx, QueryFilter{Limit: 2, Offset: 2}) + if len(page1) != 2 || len(page2) != 2 { + t.Fatalf("pages: %d, %d (want 2,2)", len(page1), len(page2)) + } + if page1[0].ID == page2[0].ID { + t.Errorf("Offset didn't skip: page2[0]=%s same as page1[0]", page2[0].ID) + } + // Offset past the end returns empty. + tail, _ := ml.Query(ctx, QueryFilter{Limit: 2, Offset: 999}) + if len(tail) != 0 { + t.Errorf("offset past end returned %d events", len(tail)) + } + // Negative Offset is clamped. + clamped, _ := ml.Query(ctx, QueryFilter{Limit: 2, Offset: -5}) + if len(clamped) != 2 { + t.Errorf("negative offset returned %d, want 2", len(clamped)) + } +} + +func TestMemoryLogger_LimitClamped(t *testing.T) { + ml := NewMemoryLogger() + ctx := context.Background() + for i := 0; i < 5; i++ { + _ = ml.Log(ctx, Event{Method: "GET", Path: "/x", Status: 200, Success: true}) + } + evs, _ := ml.Query(ctx, QueryFilter{Limit: 2}) + if len(evs) != 2 { + t.Errorf("limit=2 returned %d", len(evs)) + } + evs, _ = ml.Query(ctx, QueryFilter{Limit: 10000}) + if len(evs) != 5 { + t.Errorf("limit too high returned %d, want 5", len(evs)) + } +} diff --git a/pkg/audit/postgres/store.go b/pkg/audit/postgres/store.go new file mode 100644 index 0000000..2101534 --- /dev/null +++ b/pkg/audit/postgres/store.go @@ -0,0 +1,386 @@ +// Package auditpg provides a pgx-backed implementation of audit.Logger +// shaped for api-test's HTTP request/response audit log. +package auditpg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/plexara/api-test/pkg/audit" +) + +// Store is a pgxpool-backed audit.Logger. +type Store struct { + pool *pgxpool.Pool +} + +// New constructs a Store. +func New(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// Log inserts a single event. When ev.Payload is non-nil, the matching row +// is also inserted into audit_payloads in the same transaction so summary +// and detail are committed atomically. +func (s *Store) Log(ctx context.Context, ev audit.Event) error { + if ev.ID == "" { + ev.ID = uuid.NewString() + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + _, err = tx.Exec(ctx, ` + INSERT INTO audit_events ( + id, ts, duration_ms, request_id, session_id, + user_subject, user_email, auth_type, api_key_name, + method, path, route_name, endpoint_group, status, + bytes_in, bytes_out, + success, error_message, error_category, + remote_addr, user_agent + ) VALUES ( + $1,$2,$3,$4,$5, + $6,$7,$8,$9, + $10,$11,$12,$13,$14, + $15,$16, + $17,$18,$19, + $20,$21 + ) + `, + ev.ID, ev.Timestamp, ev.DurationMS, ev.RequestID, ev.SessionID, + ev.UserSubject, ev.UserEmail, ev.AuthType, ev.APIKeyName, + ev.Method, ev.Path, ev.RouteName, ev.EndpointGroup, ev.Status, + ev.BytesIn, ev.BytesOut, + ev.Success, ev.ErrorMessage, ev.ErrorCategory, + ev.RemoteAddr, ev.UserAgent, + ) + if err != nil { + return fmt.Errorf("insert audit event: %w", err) + } + + if ev.Payload != nil { + if err := insertPayload(ctx, tx, ev.ID, ev.Payload); err != nil { + return err + } + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit audit event: %w", err) + } + return nil +} + +// insertPayload writes the audit_payloads row. Caller must hold an open +// tx; this function never commits or rolls back on its own. +func insertPayload(ctx context.Context, tx pgx.Tx, eventID string, p *audit.Payload) error { + requestHeaders, err := marshalJSONB(p.RequestHeaders) + if err != nil { + return fmt.Errorf("marshal request_headers: %w", err) + } + requestQuery, err := marshalJSONB(p.RequestQuery) + if err != nil { + return fmt.Errorf("marshal request_query: %w", err) + } + responseHeaders, err := marshalJSONB(p.ResponseHeaders) + if err != nil { + return fmt.Errorf("marshal response_headers: %w", err) + } + var replayedFrom any + if p.ReplayedFrom != "" { + replayedFrom = p.ReplayedFrom + } + // pgx treats nil []byte as SQL NULL for BYTEA, which is what we want + // when the body wasn't captured. + var requestBody any + if len(p.RequestBody) > 0 { + requestBody = p.RequestBody + } + var responseBody any + if len(p.ResponseBody) > 0 { + responseBody = p.ResponseBody + } + + _, err = tx.Exec(ctx, ` + INSERT INTO audit_payloads ( + event_id, + request_headers, request_query, request_content_type, request_body, + request_size_bytes, request_truncated, request_remote_addr, + response_headers, response_content_type, response_body, + response_size_bytes, response_truncated, + replayed_from + ) VALUES ( + $1, + $2, $3, $4, $5, + $6, $7, $8, + $9, $10, $11, + $12, $13, + $14 + ) + `, + eventID, + requestHeaders, requestQuery, p.RequestContentType, requestBody, + p.RequestSizeBytes, p.RequestTruncated, p.RequestRemoteAddr, + responseHeaders, p.ResponseContentType, responseBody, + p.ResponseSizeBytes, p.ResponseTruncated, + replayedFrom, + ) + if err != nil { + return fmt.Errorf("insert audit payload: %w", err) + } + return nil +} + +// marshalJSONB returns the JSON encoding of v, or nil for nil-or-empty +// inputs so the column stores SQL NULL. +func marshalJSONB(v any) ([]byte, error) { + if v == nil { + return nil, nil + } + if isEmptyValue(v) { + return nil, nil + } + return json.Marshal(v) +} + +func isEmptyValue(v any) bool { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Map, reflect.Slice, reflect.Array, reflect.Chan, reflect.String: + return rv.Len() == 0 + case reflect.Pointer, reflect.Interface: + return rv.IsNil() + } + return false +} + +// GetPayload returns the audit_payloads row for the given event, or +// (nil, nil) if no payload was captured. Errors other than "no rows" are +// returned. +func (s *Store) GetPayload(ctx context.Context, eventID string) (*audit.Payload, error) { + row := s.pool.QueryRow(ctx, ` + SELECT + request_headers, request_query, + COALESCE(request_content_type, ''), request_body, + request_size_bytes, request_truncated, + COALESCE(request_remote_addr, ''), + response_headers, + COALESCE(response_content_type, ''), response_body, + response_size_bytes, response_truncated, + COALESCE(replayed_from, '') + FROM audit_payloads WHERE event_id = $1 + `, eventID) + + var ( + reqHeaders, reqQuery, respHeaders []byte + reqContentType, respContentType string + reqBody, respBody []byte + reqSize, respSize int + reqTrunc, respTrunc bool + reqRemoteAddr, replayedFrom string + ) + if err := row.Scan( + &reqHeaders, &reqQuery, + &reqContentType, &reqBody, + &reqSize, &reqTrunc, + &reqRemoteAddr, + &respHeaders, + &respContentType, &respBody, + &respSize, &respTrunc, + &replayedFrom, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("query audit_payloads: %w", err) + } + + p := &audit.Payload{ + RequestContentType: reqContentType, + RequestBody: reqBody, + RequestSizeBytes: reqSize, + RequestTruncated: reqTrunc, + RequestRemoteAddr: reqRemoteAddr, + ResponseContentType: respContentType, + ResponseBody: respBody, + ResponseSizeBytes: respSize, + ResponseTruncated: respTrunc, + ReplayedFrom: replayedFrom, + } + if len(reqHeaders) > 0 { + _ = json.Unmarshal(reqHeaders, &p.RequestHeaders) + } + if len(reqQuery) > 0 { + _ = json.Unmarshal(reqQuery, &p.RequestQuery) + } + if len(respHeaders) > 0 { + _ = json.Unmarshal(respHeaders, &p.ResponseHeaders) + } + return p, nil +} + +// Query returns matching events ordered by timestamp DESC, id ASC. +func (s *Store) Query(ctx context.Context, f audit.QueryFilter) ([]audit.Event, error) { + q, args := buildSelect(f, false) + rows, err := s.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("query audit_events: %w", err) + } + defer rows.Close() + + var out []audit.Event + for rows.Next() { + var ev audit.Event + if err := scanEvent(rows, &ev); err != nil { + return nil, err + } + out = append(out, ev) + } + return out, rows.Err() +} + +// Count returns the number of matching events. Limit/Offset are ignored. +func (s *Store) Count(ctx context.Context, f audit.QueryFilter) (int64, error) { + q, args := buildSelect(f, true) + var n int64 + if err := s.pool.QueryRow(ctx, q, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("count audit_events: %w", err) + } + return n, nil +} + +// buildSelect emits the WHERE/ORDER/LIMIT clauses for either a row select +// or a count(*). The two paths share predicate construction so the count +// number always matches the result set. +func buildSelect(f audit.QueryFilter, count bool) (string, []any) { + var ( + clauses []string + args []any + ) + add := func(clause string, val any) { + args = append(args, val) + clauses = append(clauses, fmt.Sprintf(clause, len(args))) + } + if !f.From.IsZero() { + add("ts >= $%d", f.From) + } + if !f.To.IsZero() { + add("ts <= $%d", f.To) + } + if f.Method != "" { + add("method = $%d", f.Method) + } + if f.Path != "" { + add("path = $%d", f.Path) + } + if f.RouteName != "" { + add("route_name = $%d", f.RouteName) + } + if f.UserID != "" { + add("user_subject = $%d", f.UserID) + } + if f.SessionID != "" { + add("session_id = $%d", f.SessionID) + } + if f.EventID != "" { + add("id = $%d", f.EventID) + } + if f.Status != 0 { + add("status = $%d", f.Status) + } + if f.Success != nil { + add("success = $%d", *f.Success) + } + if f.Search != "" { + // Light search across path + error_message; the portal can wire a + // proper trgm/fts index later. Bind the value once, reference it + // from two placeholders. + args = append(args, "%"+f.Search+"%") + idx := len(args) + clauses = append(clauses, fmt.Sprintf("(path ILIKE $%d OR error_message ILIKE $%d)", idx, idx)) + } + + where := "" + if len(clauses) > 0 { + where = "WHERE " + strings.Join(clauses, " AND ") + } + + if count { + return "SELECT count(*) FROM audit_events " + where, args + } + + limit := f.Limit + if limit <= 0 { + limit = 100 + } + if limit > audit.MaxQueryLimit { + limit = audit.MaxQueryLimit + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + q := ` + SELECT + id, ts, duration_ms, COALESCE(request_id,''), COALESCE(session_id,''), + COALESCE(user_subject,''), COALESCE(user_email,''), + COALESCE(auth_type,''), COALESCE(api_key_name,''), + method, path, COALESCE(route_name,''), COALESCE(endpoint_group,''), + status, bytes_in, bytes_out, + success, COALESCE(error_message,''), COALESCE(error_category,''), + COALESCE(remote_addr,''), COALESCE(user_agent,'') + FROM audit_events ` + where + ` + ORDER BY ts DESC, id ASC + LIMIT ` + itoa(limit) + ` OFFSET ` + itoa(offset) + return q, args +} + +// scanEvent reads one row into ev. Caller controls rows.Next. +func scanEvent(rows pgx.Rows, ev *audit.Event) error { + var ts time.Time + if err := rows.Scan( + &ev.ID, &ts, &ev.DurationMS, &ev.RequestID, &ev.SessionID, + &ev.UserSubject, &ev.UserEmail, &ev.AuthType, &ev.APIKeyName, + &ev.Method, &ev.Path, &ev.RouteName, &ev.EndpointGroup, + &ev.Status, &ev.BytesIn, &ev.BytesOut, + &ev.Success, &ev.ErrorMessage, &ev.ErrorCategory, + &ev.RemoteAddr, &ev.UserAgent, + ); err != nil { + return fmt.Errorf("scan audit row: %w", err) + } + ev.Timestamp = ts + return nil +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/pkg/auth/inbound/apikey.go b/pkg/auth/inbound/apikey.go new file mode 100644 index 0000000..c6ad70e --- /dev/null +++ b/pkg/auth/inbound/apikey.go @@ -0,0 +1,142 @@ +package inbound + +import ( + "context" + "crypto/subtle" + "errors" + "net/http" + "strings" + + "github.com/plexara/api-test/pkg/config" +) + +// ErrNoCredential is returned when the request carried no credential the +// authenticator recognizes (no Authorization header, no X-API-Key header, +// no api_key query). Distinct from a credential that was supplied but +// wrong; chain.go decides what to do with each (anonymous fallback vs +// 401). +var ErrNoCredential = errors.New("inbound: no credential") + +// ErrInvalidCredential is returned when a credential was supplied but did +// not validate (wrong key, expired token, bad signature). The auth chain +// converts this into a 401. +var ErrInvalidCredential = errors.New("inbound: invalid credential") + +// APIKeyStore is the lookup interface the API-key authenticator uses. +// Both the file-backed map and the bcrypt-backed Postgres store +// (pkg/apikeys) implement it. +type APIKeyStore interface { + // LookupAPIKey returns the key name on success, ErrInvalidCredential + // when the plaintext doesn't match anything, or another error for + // transport failures. Implementations must be safe under concurrent + // use. + LookupAPIKey(ctx context.Context, plaintext string) (name string, err error) +} + +// APIKeyAuthenticator validates X-API-Key (header) and ?api_key= (query) +// against an APIKeyStore. +type APIKeyAuthenticator struct { + store APIKeyStore + headerName string + queryParam string +} + +// NewAPIKey returns an APIKeyAuthenticator. headerName and queryParam +// default to "X-API-Key" and "api_key" when empty. +func NewAPIKey(store APIKeyStore, headerName, queryParam string) *APIKeyAuthenticator { + if headerName == "" { + headerName = "X-API-Key" + } + if queryParam == "" { + queryParam = "api_key" + } + return &APIKeyAuthenticator{store: store, headerName: headerName, queryParam: queryParam} +} + +// Authenticate implements Authenticator. +// +// Preference order: header first, query second. If both are present and +// differ, the header wins (a malicious or curious caller can't override +// the credential by appending a query param). +func (a *APIKeyAuthenticator) Authenticate(ctx context.Context, r *http.Request) (*Identity, error) { + candidate := strings.TrimSpace(r.Header.Get(a.headerName)) + if candidate == "" { + candidate = strings.TrimSpace(r.URL.Query().Get(a.queryParam)) + } + if candidate == "" { + return nil, ErrNoCredential + } + name, err := a.store.LookupAPIKey(ctx, candidate) + if err != nil { + return nil, err + } + return &Identity{ + Subject: name, + AuthType: "apikey", + KeyName: name, + }, nil +} + +// FileAPIKeyStore is an in-memory APIKeyStore backed by config.FileAPIKey +// entries. Lookup is constant-time (subtle.ConstantTimeCompare per row) +// to avoid timing-side-channel guesses. +type FileAPIKeyStore struct { + keys []config.FileAPIKey +} + +// NewFileAPIKeyStore returns a FileAPIKeyStore over the provided keys. +func NewFileAPIKeyStore(keys []config.FileAPIKey) *FileAPIKeyStore { + cp := make([]config.FileAPIKey, len(keys)) + copy(cp, keys) + return &FileAPIKeyStore{keys: cp} +} + +// LookupAPIKey implements APIKeyStore. +func (s *FileAPIKeyStore) LookupAPIKey(_ context.Context, candidate string) (string, error) { + if candidate == "" { + return "", ErrInvalidCredential + } + cb := []byte(candidate) + // Walk every entry so timing doesn't leak which entry matched. + matched := "" + for _, k := range s.keys { + if subtle.ConstantTimeCompare(cb, []byte(k.Key)) == 1 { + matched = k.Name + } + } + if matched == "" { + return "", ErrInvalidCredential + } + return matched, nil +} + +// CombineAPIKeyStores returns an APIKeyStore that consults each store in +// order; the first non-error hit wins. Used by the chain to layer the +// file store + the DB-backed bcrypt store. +func CombineAPIKeyStores(stores ...APIKeyStore) APIKeyStore { + return &combined{stores: stores} +} + +type combined struct { + stores []APIKeyStore +} + +func (c *combined) LookupAPIKey(ctx context.Context, plaintext string) (string, error) { + var lastErr error + for _, s := range c.stores { + name, err := s.LookupAPIKey(ctx, plaintext) + if err == nil { + return name, nil + } + // Both ErrInvalidCredential and transport errors continue the + // loop; only ErrInvalidCredential is treated as "next store can + // still match" — transport errors are bubbled up after the loop. + if !errors.Is(err, ErrInvalidCredential) { + lastErr = err + } + } + if lastErr != nil { + return "", lastErr + } + return "", ErrInvalidCredential +} diff --git a/pkg/auth/inbound/bearer.go b/pkg/auth/inbound/bearer.go new file mode 100644 index 0000000..f5c55e3 --- /dev/null +++ b/pkg/auth/inbound/bearer.go @@ -0,0 +1,68 @@ +package inbound + +import ( + "context" + "crypto/subtle" + "net/http" + "strings" + + "github.com/plexara/api-test/pkg/config" +) + +// BearerAuthenticator validates `Authorization: Bearer ` against a +// fixed list of (name, token) pairs from config. +// +// OIDC-style bearer-JWT validation lives in oauth2.go (M3); both +// authenticators read the Authorization header but they're disjoint: the +// chain tries the OIDC validator first when configured, falling back to +// the static list, so a JWT from Keycloak doesn't accidentally match a +// static token. +type BearerAuthenticator struct { + tokens []config.FileBearerToken +} + +// NewBearer returns a BearerAuthenticator over the provided token list. +func NewBearer(tokens []config.FileBearerToken) *BearerAuthenticator { + cp := make([]config.FileBearerToken, len(tokens)) + copy(cp, tokens) + return &BearerAuthenticator{tokens: cp} +} + +// Authenticate implements Authenticator. +func (b *BearerAuthenticator) Authenticate(_ context.Context, r *http.Request) (*Identity, error) { + candidate := extractBearer(r.Header.Get("Authorization")) + if candidate == "" { + return nil, ErrNoCredential + } + cb := []byte(candidate) + matched := "" + for _, t := range b.tokens { + if subtle.ConstantTimeCompare(cb, []byte(t.Token)) == 1 { + matched = t.Name + } + } + if matched == "" { + return nil, ErrInvalidCredential + } + return &Identity{ + Subject: matched, + AuthType: "bearer", + KeyName: matched, + }, nil +} + +// extractBearer returns the token portion of an "Authorization: Bearer X" +// header value, or empty when the scheme isn't Bearer. +func extractBearer(authHeader string) string { + if authHeader == "" { + return "" + } + const prefix = "Bearer " + if len(authHeader) < len(prefix) { + return "" + } + if !strings.EqualFold(authHeader[:len(prefix)], prefix) { + return "" + } + return strings.TrimSpace(authHeader[len(prefix):]) +} diff --git a/pkg/auth/inbound/chain.go b/pkg/auth/inbound/chain.go new file mode 100644 index 0000000..0d11319 --- /dev/null +++ b/pkg/auth/inbound/chain.go @@ -0,0 +1,64 @@ +package inbound + +import ( + "context" + "errors" + "net/http" +) + +// Authenticator validates a single credential type and returns the +// resolved Identity on success, ErrNoCredential when the request didn't +// carry the credential type at all (the chain should try the next), or +// any other error to abort with 401. +type Authenticator interface { + Authenticate(ctx context.Context, r *http.Request) (*Identity, error) +} + +// Chain is an ordered list of Authenticators. The first one that finds +// its credential type in the request decides the outcome: +// - returns Identity on validation success +// - returns ErrInvalidCredential on validation failure (no fallthrough) +// - returns ErrNoCredential to advance to the next authenticator +// +// When every authenticator returns ErrNoCredential and AllowAnonymous is +// true, the chain returns Anonymous(); otherwise ErrNoCredential bubbles +// out and the caller should respond 401. +type Chain struct { + authenticators []Authenticator + allowAnonymous bool +} + +// NewChain returns a Chain. allowAnonymous controls the no-credential +// fallback (true → anonymous Identity, false → ErrNoCredential). +func NewChain(allowAnonymous bool, auths ...Authenticator) *Chain { + out := make([]Authenticator, 0, len(auths)) + for _, a := range auths { + if a != nil { + out = append(out, a) + } + } + return &Chain{authenticators: out, allowAnonymous: allowAnonymous} +} + +// Authenticate walks the chain. See Chain doc for semantics. +func (c *Chain) Authenticate(ctx context.Context, r *http.Request) (*Identity, error) { + for _, a := range c.authenticators { + id, err := a.Authenticate(ctx, r) + if err == nil { + return id, nil + } + if errors.Is(err, ErrNoCredential) { + continue + } + return nil, err + } + if c.allowAnonymous { + return Anonymous(), nil + } + return nil, ErrNoCredential +} + +// AllowAnonymous reports whether the chain falls back to anonymous when +// no credential matches. Used by the inbound middleware to decide whether +// 401 is appropriate. +func (c *Chain) AllowAnonymous() bool { return c.allowAnonymous } diff --git a/pkg/auth/inbound/identity.go b/pkg/auth/inbound/identity.go new file mode 100644 index 0000000..79de97e --- /dev/null +++ b/pkg/auth/inbound/identity.go @@ -0,0 +1,65 @@ +// Package inbound validates the credentials a Plexara API gateway connection +// presents to api-test. The chain implements the Authenticator interface +// over multiple credential sources (file API keys, DB API keys, static +// bearer tokens, OIDC bearer tokens) and resolves them into an Identity +// that downstream handlers and the audit middleware can read off the +// request context. +// +// OIDC validation lands in M3 (Keycloak); M2 ships file/DB API key + bearer. +package inbound + +import ( + "context" +) + +// Identity is the resolved caller identity for an inbound HTTP request. +// Populated by the inbound auth middleware (pkg/httpmw) and stashed in +// the request context for handlers and the audit middleware. +type Identity struct { + // Subject is the canonical principal identifier: API key name, bearer + // token name, or OIDC subject claim. + Subject string + + // Email is best-effort; populated for OIDC tokens that carry the + // claim. Empty for API-key / static-bearer auth. + Email string + + // AuthType is the high-level auth scheme: "anonymous", "apikey", + // "bearer", "oauth2". Used by the audit row. + AuthType string + + // KeyName is the credential's display name (e.g. "devkey"). For OIDC + // it's the validated client_id. Empty for anonymous. + KeyName string + + // Scopes is the list of OAuth2 scopes (or roles) granted. + Scopes []string + + // Claims is the raw OIDC claim map for debugging/portal display. + // Nil for non-OIDC auth. + Claims map[string]any +} + +// Anonymous returns an Identity for unauthenticated requests when the +// server is configured with auth.allow_anonymous=true. +func Anonymous() *Identity { + return &Identity{Subject: "", AuthType: "anonymous"} +} + +// ctxKey is the package-private context key type so other packages can't +// stomp on our value. +type ctxKey struct{} + +// WithIdentity returns ctx with id attached. Used by the inbound auth +// middleware (pkg/httpmw/identity.go) right before invoking the handler. +func WithIdentity(ctx context.Context, id *Identity) context.Context { + return context.WithValue(ctx, ctxKey{}, id) +} + +// FromContext returns the Identity attached to ctx, or nil if absent. +// Handlers should treat nil as "auth chain hasn't run yet" — distinct +// from anonymous, which has a non-nil Identity with AuthType="anonymous". +func FromContext(ctx context.Context) *Identity { + id, _ := ctx.Value(ctxKey{}).(*Identity) + return id +} diff --git a/pkg/auth/inbound/inbound_test.go b/pkg/auth/inbound/inbound_test.go new file mode 100644 index 0000000..2980fe2 --- /dev/null +++ b/pkg/auth/inbound/inbound_test.go @@ -0,0 +1,201 @@ +package inbound + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/plexara/api-test/pkg/config" +) + +func TestFileAPIKeyStore_HitAndMiss(t *testing.T) { + s := NewFileAPIKeyStore([]config.FileAPIKey{ + {Name: "alpha", Key: "AAA"}, + {Name: "beta", Key: "BBB"}, + }) + if name, err := s.LookupAPIKey(context.Background(), "AAA"); err != nil || name != "alpha" { + t.Errorf("hit AAA: name=%q err=%v", name, err) + } + if _, err := s.LookupAPIKey(context.Background(), "ZZZ"); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("miss: err=%v want ErrInvalidCredential", err) + } + if _, err := s.LookupAPIKey(context.Background(), ""); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("empty: err=%v", err) + } +} + +func TestAPIKeyAuthenticator_HeaderAndQuery(t *testing.T) { + store := NewFileAPIKeyStore([]config.FileAPIKey{{Name: "k1", Key: "secret"}}) + a := NewAPIKey(store, "X-API-Key", "api_key") + + t.Run("header hit", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-API-Key", "secret") + id, err := a.Authenticate(context.Background(), r) + if err != nil { + t.Fatal(err) + } + if id.Subject != "k1" || id.AuthType != "apikey" { + t.Errorf("identity = %+v", id) + } + }) + t.Run("query hit", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/?api_key=secret", nil) + id, err := a.Authenticate(context.Background(), r) + if err != nil || id.Subject != "k1" { + t.Errorf("got id=%+v err=%v", id, err) + } + }) + t.Run("header wins", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/?api_key=wrong", nil) + r.Header.Set("X-API-Key", "secret") + if _, err := a.Authenticate(context.Background(), r); err != nil { + t.Errorf("header-wins err: %v", err) + } + }) + t.Run("no credential", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + _, err := a.Authenticate(context.Background(), r) + if !errors.Is(err, ErrNoCredential) { + t.Errorf("got err=%v want ErrNoCredential", err) + } + }) + t.Run("invalid", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-API-Key", "wrong") + _, err := a.Authenticate(context.Background(), r) + if !errors.Is(err, ErrInvalidCredential) { + t.Errorf("got err=%v want ErrInvalidCredential", err) + } + }) +} + +func TestBearer_HitAndMiss(t *testing.T) { + a := NewBearer([]config.FileBearerToken{{Name: "tok", Token: "abc123"}}) + + t.Run("hit", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer abc123") + id, err := a.Authenticate(context.Background(), r) + if err != nil || id.Subject != "tok" || id.AuthType != "bearer" { + t.Errorf("id=%+v err=%v", id, err) + } + }) + t.Run("case-insensitive scheme", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "bearer abc123") + if _, err := a.Authenticate(context.Background(), r); err != nil { + t.Errorf("err %v", err) + } + }) + t.Run("missing", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + _, err := a.Authenticate(context.Background(), r) + if !errors.Is(err, ErrNoCredential) { + t.Errorf("err %v", err) + } + }) + t.Run("non-bearer scheme", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + _, err := a.Authenticate(context.Background(), r) + if !errors.Is(err, ErrNoCredential) { + t.Errorf("err %v", err) + } + }) + t.Run("wrong token", func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer nope") + _, err := a.Authenticate(context.Background(), r) + if !errors.Is(err, ErrInvalidCredential) { + t.Errorf("err %v", err) + } + }) +} + +type stubAuth struct { + id *Identity + err error +} + +func (s stubAuth) Authenticate(context.Context, *http.Request) (*Identity, error) { + return s.id, s.err +} + +func TestChain_FirstMatchWins(t *testing.T) { + c := NewChain(false, + stubAuth{err: ErrNoCredential}, + stubAuth{id: &Identity{Subject: "x", AuthType: "apikey"}}, + stubAuth{id: &Identity{Subject: "y", AuthType: "bearer"}}, + ) + r := httptest.NewRequest(http.MethodGet, "/", nil) + id, err := c.Authenticate(context.Background(), r) + if err != nil { + t.Fatal(err) + } + if id.Subject != "x" { + t.Errorf("got subject %q want x", id.Subject) + } +} + +func TestChain_InvalidStops(t *testing.T) { + c := NewChain(true, + stubAuth{err: ErrInvalidCredential}, + stubAuth{id: &Identity{Subject: "shouldNotReach"}}, + ) + r := httptest.NewRequest(http.MethodGet, "/", nil) + _, err := c.Authenticate(context.Background(), r) + if !errors.Is(err, ErrInvalidCredential) { + t.Errorf("err %v", err) + } +} + +func TestChain_AnonymousFallback(t *testing.T) { + c := NewChain(true, stubAuth{err: ErrNoCredential}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + id, err := c.Authenticate(context.Background(), r) + if err != nil { + t.Fatal(err) + } + if id.AuthType != "anonymous" { + t.Errorf("authType = %q want anonymous", id.AuthType) + } +} + +func TestChain_NoAnonymousReturnsErr(t *testing.T) { + c := NewChain(false, stubAuth{err: ErrNoCredential}) + r := httptest.NewRequest(http.MethodGet, "/", nil) + _, err := c.Authenticate(context.Background(), r) + if !errors.Is(err, ErrNoCredential) { + t.Errorf("err %v want ErrNoCredential", err) + } +} + +func TestContext_Roundtrip(t *testing.T) { + want := &Identity{Subject: "z", AuthType: "apikey"} + ctx := WithIdentity(context.Background(), want) + got := FromContext(ctx) + if got != want { + t.Errorf("got %+v want %+v", got, want) + } + if FromContext(context.Background()) != nil { + t.Error("FromContext on bare ctx should return nil") + } +} + +func TestCombineAPIKeyStores(t *testing.T) { + s1 := NewFileAPIKeyStore([]config.FileAPIKey{{Name: "a", Key: "AAA"}}) + s2 := NewFileAPIKeyStore([]config.FileAPIKey{{Name: "b", Key: "BBB"}}) + c := CombineAPIKeyStores(s1, s2) + if name, err := c.LookupAPIKey(context.Background(), "AAA"); err != nil || name != "a" { + t.Errorf("AAA: name=%q err=%v", name, err) + } + if name, err := c.LookupAPIKey(context.Background(), "BBB"); err != nil || name != "b" { + t.Errorf("BBB: name=%q err=%v", name, err) + } + if _, err := c.LookupAPIKey(context.Background(), "ZZZ"); !errors.Is(err, ErrInvalidCredential) { + t.Errorf("miss: err=%v", err) + } +} diff --git a/pkg/build/build.go b/pkg/build/build.go new file mode 100644 index 0000000..35558af --- /dev/null +++ b/pkg/build/build.go @@ -0,0 +1,11 @@ +// Package build holds version metadata stamped at link time via -ldflags -X. +package build + +// Version is the human-readable build version (git tag / "dev" if unstamped). +var Version = "dev" + +// Commit is the short git SHA the binary was built from. +var Commit = "none" + +// Date is the UTC build timestamp in RFC 3339. +var Date = "unknown" diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..c2af7de --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,388 @@ +// Package config loads YAML configuration with ${VAR:-default} env interpolation. +package config + +import ( + "errors" + "fmt" + "os" + "regexp" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Config is the top-level configuration document. +type Config struct { + Server ServerConfig `yaml:"server"` + OIDC OIDCConfig `yaml:"oidc"` + APIKeys APIKeysConfig `yaml:"api_keys"` + Bearer BearerConfig `yaml:"bearer"` + Auth AuthConfig `yaml:"auth"` + Database DatabaseConfig `yaml:"database"` + Audit AuditConfig `yaml:"audit"` + Portal PortalConfig `yaml:"portal"` + Endpoints EndpointsConfig `yaml:"endpoints"` + Plexara PlexaraConfig `yaml:"plexara"` +} + +// ServerConfig holds the HTTP listener and lifecycle settings. +type ServerConfig struct { + Name string `yaml:"name"` + Address string `yaml:"address"` + BaseURL string `yaml:"base_url"` + Description string `yaml:"description"` + ReadHeaderTimeout time.Duration `yaml:"read_header_timeout"` + Shutdown ShutdownConfig `yaml:"shutdown"` + TLS TLSConfig `yaml:"tls"` +} + +// DefaultServerDescription is the operator-facing description rendered in the +// OpenAPI document and the portal About page. +const DefaultServerDescription = `api-test is a controllable HTTP REST fixture used to exercise API +gateways (Plexara's in particular) end-to-end. + +Endpoints are deliberately simple and deterministic. Their job is not to +compute anything useful; their job is to make a gateway's behavior +observable. Every request is recorded in a Postgres-backed audit log, +so a tester can compare what a client sent, what reached this server, +and what came back. + +Endpoints are grouped by what they help you test: + - identity: whoami, headers, echo - verify the gateway forwards + identity, args, and HTTP headers, with redaction. + - data: fixed, sized, lorem - deterministic outputs for + testing dedup, response-size handling, and caching. + - failure: status, slow, flaky - controlled failure modes + (errors, latency, probabilistic flakiness) for retry + and timeout policy testing. + - streaming: chunked, sse, ndjson - long-running responses. + - pagination: link, odata, cursor variants - one endpoint per cursor + style the gateway recognizes. + - methods: GET/POST/PUT/PATCH/DELETE/HEAD on /v1/method/echo. + - security: probe targets the gateway should refuse to forward. + - export: large/long-running targets exercising api_export. + - echo: catch-all that returns the request verbatim. + +This server is not a data source. Do not call it for real information.` + +// ShutdownConfig tunes graceful-drain behavior. +type ShutdownConfig struct { + GracePeriod time.Duration `yaml:"grace_period"` + PreShutdownDelay time.Duration `yaml:"pre_shutdown_delay"` +} + +// TLSConfig configures optional in-process TLS. +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` +} + +// OIDCConfig configures OAuth2 bearer-token validation against an external IdP +// (Keycloak in dev). Used by the inbound auth chain to validate JWTs the +// Plexara gateway sends after exchanging client_credentials or auth_code with +// the IdP, AND by the portal's browser-login flow. +type OIDCConfig struct { + Enabled bool `yaml:"enabled"` + Issuer string `yaml:"issuer"` + Audience string `yaml:"audience"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + AllowedClients []string `yaml:"allowed_clients"` + ClockSkewSeconds int `yaml:"clock_skew_seconds"` + JWKSCacheTTL time.Duration `yaml:"jwks_cache_ttl"` + SkipSignatureVerification bool `yaml:"skip_signature_verification"` +} + +// APIKeysConfig groups file and DB API key sources. +type APIKeysConfig struct { + File []FileAPIKey `yaml:"file"` + DB APIKeysDBConfig `yaml:"db"` + HeaderName string `yaml:"header_name"` + QueryParamName string `yaml:"query_param_name"` +} + +// FileAPIKey is a single plaintext key loaded from config. +type FileAPIKey struct { + Name string `yaml:"name"` + Key string `yaml:"key"` + Description string `yaml:"description"` +} + +// APIKeysDBConfig toggles the bcrypt-hashed Postgres key store. +type APIKeysDBConfig struct { + Enabled bool `yaml:"enabled"` +} + +// BearerConfig holds static bearer tokens accepted by the inbound auth chain. +// These let a Plexara connection use auth_mode=bearer without needing a JWT. +type BearerConfig struct { + Tokens []FileBearerToken `yaml:"tokens"` +} + +// FileBearerToken is a single plaintext bearer token loaded from config. +type FileBearerToken struct { + Name string `yaml:"name"` + Token string `yaml:"token"` + Description string `yaml:"description"` +} + +// AuthConfig controls server-wide auth requirements. +type AuthConfig struct { + AllowAnonymous bool `yaml:"allow_anonymous"` + RequireForAPI bool `yaml:"require_for_api"` + RequireForPortal bool `yaml:"require_for_portal"` +} + +// DatabaseConfig configures the pgx connection pool. Empty URL is allowed in +// M1 (audit disabled, no DB-backed keys); validate enforces presence only when +// audit or DB-backed features need it. +type DatabaseConfig struct { + URL string `yaml:"url"` + MaxOpenConns int32 `yaml:"max_open_conns"` + MaxIdleConns int32 `yaml:"max_idle_conns"` + ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"` +} + +// AuditConfig controls audit log behavior and parameter redaction. +// +// Payload capture (request/response envelope, headers) is on by default +// because api-test is a test fixture and full visibility is the entire +// point. Operators can flip CapturePayloads off to keep only the indexable +// summary. +type AuditConfig struct { + Enabled bool `yaml:"enabled"` + RetentionDays int `yaml:"retention_days"` + RedactKeys []string `yaml:"redact_keys"` + + // CapturePayloads enables writing the audit_payloads sibling row + // alongside each summary. Default true (nil pointer = unset = on). + CapturePayloads *bool `yaml:"capture_payloads"` + + // CaptureHeaders includes the redacted HTTP headers in the payload row. + // Default true; set false in deployments where headers carry data the + // operator doesn't want stored even after redaction. + CaptureHeaders *bool `yaml:"capture_headers"` + + // MaxPayloadBytes caps per-side (request, response) payload size. + // Anything beyond is dropped and the matching truncated flag is set. + // Default 1 MiB (api-test responses can be much larger than mcp-test). + MaxPayloadBytes int `yaml:"max_payload_bytes"` +} + +// CapturePayloadsEnabled returns true unless the operator explicitly set +// CapturePayloads to false. +func (a AuditConfig) CapturePayloadsEnabled() bool { + if a.CapturePayloads == nil { + return true + } + return *a.CapturePayloads +} + +// CaptureHeadersEnabled returns true unless the operator explicitly set +// CaptureHeaders to false. +func (a AuditConfig) CaptureHeadersEnabled() bool { + if a.CaptureHeaders == nil { + return true + } + return *a.CaptureHeaders +} + +// PortalConfig configures the embedded React portal and its session cookie. +type PortalConfig struct { + Enabled bool `yaml:"enabled"` + CookieName string `yaml:"cookie_name"` + CookieSecret string `yaml:"cookie_secret"` + CookieSecure bool `yaml:"cookie_secure"` + OIDCRedirectPath string `yaml:"oidc_redirect_path"` +} + +// EndpointsConfig toggles each endpoint group on or off. +type EndpointsConfig struct { + Identity EndpointGroupConfig `yaml:"identity"` + Data EndpointGroupConfig `yaml:"data"` + Failure EndpointGroupConfig `yaml:"failure"` + Streaming EndpointGroupConfig `yaml:"streaming"` + Pagination EndpointGroupConfig `yaml:"pagination"` + Methods EndpointGroupConfig `yaml:"methods"` + Security EndpointGroupConfig `yaml:"security"` + Export EndpointGroupConfig `yaml:"export"` + Echo EndpointGroupConfig `yaml:"echo"` +} + +// EndpointGroupConfig is the per-group toggle. +type EndpointGroupConfig struct { + Enabled bool `yaml:"enabled"` +} + +// PlexaraConfig optionally registers api-test as a connection in a running +// Plexara instance on startup. Default off; ship example YAML for manual use. +type PlexaraConfig struct { + Register PlexaraRegisterConfig `yaml:"register"` +} + +// PlexaraRegisterConfig holds the admin API target for self-registration. +type PlexaraRegisterConfig struct { + Enabled bool `yaml:"enabled"` + AdminURL string `yaml:"admin_url"` + AuthHeader string `yaml:"auth_header"` + ConnName string `yaml:"connection_name"` +} + +// Load reads, env-expands, and validates a YAML config file. +func Load(path string) (*Config, error) { + // #nosec G304 -- path comes from the operator's --config flag; this is + // the intended entry point and the binary trusts its CLI args. + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", path, err) + } + expanded := expandEnv(string(raw)) + + var cfg Config + if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + cfg.applyDefaults() + if err := cfg.Validate(); err != nil { + return nil, err + } + return &cfg, nil +} + +// applyDefaults fills empty fields with reasonable defaults. +func (c *Config) applyDefaults() { + if c.Server.Name == "" { + c.Server.Name = "api-test" + } + if c.Server.Address == "" { + c.Server.Address = ":8080" + } + if c.Server.BaseURL == "" { + c.Server.BaseURL = "http://localhost" + portFromAddr(c.Server.Address) + } + if c.Server.Description == "" { + c.Server.Description = DefaultServerDescription + } + if c.Server.ReadHeaderTimeout == 0 { + c.Server.ReadHeaderTimeout = 10 * time.Second + } + if c.Server.Shutdown.GracePeriod == 0 { + c.Server.Shutdown.GracePeriod = 25 * time.Second + } + if c.Server.Shutdown.PreShutdownDelay == 0 { + c.Server.Shutdown.PreShutdownDelay = 2 * time.Second + } + if c.OIDC.ClockSkewSeconds == 0 { + c.OIDC.ClockSkewSeconds = 30 + } + if c.OIDC.JWKSCacheTTL == 0 { + c.OIDC.JWKSCacheTTL = time.Hour + } + if c.APIKeys.HeaderName == "" { + c.APIKeys.HeaderName = "X-API-Key" + } + if c.APIKeys.QueryParamName == "" { + c.APIKeys.QueryParamName = "api_key" + } + if c.Database.MaxOpenConns == 0 { + c.Database.MaxOpenConns = 25 + } + if c.Database.MaxIdleConns == 0 { + c.Database.MaxIdleConns = 5 + } + if c.Database.ConnMaxLifetime == 0 { + c.Database.ConnMaxLifetime = time.Hour + } + if c.Audit.RetentionDays == 0 { + // Lower than mcp-test's 30 because api-test responses can be much + // larger; export endpoints emit 100 MiB bodies that inflate the + // audit_payloads table fast. + c.Audit.RetentionDays = 7 + } + if c.Audit.MaxPayloadBytes == 0 { + c.Audit.MaxPayloadBytes = 1 << 20 // 1 MiB + } + if len(c.Audit.RedactKeys) == 0 { + // Matched as case-insensitive substrings against parameter keys + // and header names. Operators should extend this for + // domain-specific secret naming. + c.Audit.RedactKeys = []string{ + "password", "token", "secret", "authorization", "api_key", + "api-key", "credentials", "bearer", "cookie", "jwt", + "session_id", "private_key", "passwd", + } + } + if c.Portal.CookieName == "" { + c.Portal.CookieName = "api_test_session" + } + if c.Portal.OIDCRedirectPath == "" { + c.Portal.OIDCRedirectPath = "/portal/auth/callback" + } + if c.Plexara.Register.ConnName == "" { + c.Plexara.Register.ConnName = "api-test" + } +} + +// Validate fails fast on impossible or insecure configurations. +func (c *Config) Validate() error { + var errs []string + // Database is only required when audit is enabled or DB-backed + // features are turned on. M1 deployments can run anonymous + no audit. + if (c.Audit.Enabled || c.APIKeys.DB.Enabled) && c.Database.URL == "" { + errs = append(errs, "database.url is required when audit.enabled or api_keys.db.enabled") + } + if c.Portal.Enabled && c.Portal.CookieSecret == "" { + errs = append(errs, "portal.cookie_secret is required when portal.enabled=true") + } + if c.OIDC.Enabled && c.OIDC.Issuer == "" { + errs = append(errs, "oidc.issuer is required when oidc.enabled=true") + } + if c.OIDC.SkipSignatureVerification && os.Getenv("APITEST_INSECURE") != "1" { + errs = append(errs, "oidc.skip_signature_verification requires APITEST_INSECURE=1") + } + hasInboundAuth := c.OIDC.Enabled || + len(c.APIKeys.File) > 0 || + c.APIKeys.DB.Enabled || + len(c.Bearer.Tokens) > 0 + if !hasInboundAuth && !c.Auth.AllowAnonymous { + errs = append(errs, "no inbound auth method enabled: configure oidc, api_keys, bearer.tokens, or auth.allow_anonymous") + } + if c.Plexara.Register.Enabled && c.Plexara.Register.AdminURL == "" { + errs = append(errs, "plexara.register.admin_url is required when plexara.register.enabled=true") + } + if len(errs) > 0 { + return errors.New("invalid config: " + strings.Join(errs, "; ")) + } + return nil +} + +// expandEnv expands ${VAR} and ${VAR:-default} forms in s using os.LookupEnv. +// +// Plain $VAR is intentionally left untouched; config values often contain +// shell-like syntax (e.g. Postgres connection strings) that we don't want +// to rewrite. +var envPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`) + +func expandEnv(s string) string { + return envPattern.ReplaceAllStringFunc(s, func(match string) string { + groups := envPattern.FindStringSubmatch(match) + if len(groups) == 0 { + return match + } + name, def := groups[1], groups[2] + if v, ok := os.LookupEnv(name); ok { + return v + } + return def + }) +} + +// portFromAddr returns the :port suffix from an address like ":8080" or "0.0.0.0:8080". +func portFromAddr(addr string) string { + if i := strings.LastIndex(addr, ":"); i >= 0 { + return addr[i:] + } + return ":8080" +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..d874637 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,144 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTemp(t *testing.T, contents string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "cfg.yaml") + if err := os.WriteFile(p, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestLoad_AnonymousMinimal(t *testing.T) { + // Anonymous + audit off + DB-keys off: should pass without database.url. + cfg, err := Load(writeTemp(t, ` +auth: + allow_anonymous: true +endpoints: + identity: { enabled: true } +`)) + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.Server.Name != "api-test" { + t.Errorf("default name not applied: %q", cfg.Server.Name) + } + if cfg.Server.Address != ":8080" { + t.Errorf("default address not applied: %q", cfg.Server.Address) + } + if cfg.Audit.RetentionDays != 7 { + t.Errorf("audit retention default: got %d want 7", cfg.Audit.RetentionDays) + } + if cfg.APIKeys.HeaderName != "X-API-Key" { + t.Errorf("apikeys header default: %q", cfg.APIKeys.HeaderName) + } + if !cfg.Audit.CapturePayloadsEnabled() { + t.Error("CapturePayloadsEnabled default should be true") + } +} + +func TestLoad_RequiresAuth(t *testing.T) { + _, err := Load(writeTemp(t, ` +auth: + allow_anonymous: false +endpoints: + identity: { enabled: true } +`)) + if err == nil { + t.Fatal("expected validation error for missing auth source") + } + if !strings.Contains(err.Error(), "no inbound auth") { + t.Errorf("wrong error: %v", err) + } +} + +func TestLoad_AuditRequiresDB(t *testing.T) { + _, err := Load(writeTemp(t, ` +auth: + allow_anonymous: true +audit: + enabled: true +endpoints: + identity: { enabled: true } +`)) + if err == nil || !strings.Contains(err.Error(), "database.url is required") { + t.Fatalf("expected db.url error, got %v", err) + } +} + +func TestLoad_PortalRequiresCookieSecret(t *testing.T) { + _, err := Load(writeTemp(t, ` +auth: + allow_anonymous: true +portal: + enabled: true +endpoints: + identity: { enabled: true } +`)) + if err == nil || !strings.Contains(err.Error(), "portal.cookie_secret") { + t.Fatalf("expected cookie_secret error, got %v", err) + } +} + +func TestLoad_PlexaraRegisterRequiresAdminURL(t *testing.T) { + _, err := Load(writeTemp(t, ` +auth: + allow_anonymous: true +plexara: + register: + enabled: true +endpoints: + identity: { enabled: true } +`)) + if err == nil || !strings.Contains(err.Error(), "plexara.register.admin_url") { + t.Fatalf("expected admin_url error, got %v", err) + } +} + +func TestExpandEnv(t *testing.T) { + t.Setenv("APITEST_TEST_VAR", "hello") + got := expandEnv("a=${APITEST_TEST_VAR} b=${APITEST_NOT_SET:-fallback} c=${APITEST_NOT_SET}") + want := "a=hello b=fallback c=" + if got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestExpandEnv_PreservesPlainDollar(t *testing.T) { + // Plain $VAR (no braces) must stay untouched; Postgres connection + // strings rely on this. + in := "host=$HOST_NOT_SET sslmode=disable" + if got := expandEnv(in); got != in { + t.Errorf("got %q want %q", got, in) + } +} + +func TestCaptureHeadersEnabled_ExplicitFalse(t *testing.T) { + f := false + cfg := AuditConfig{CaptureHeaders: &f} + if cfg.CaptureHeadersEnabled() { + t.Error("explicit false should be respected") + } +} + +func TestPortFromAddr(t *testing.T) { + for in, want := range map[string]string{ + ":8080": ":8080", + "0.0.0.0:8080": ":8080", + "127.0.0.1:443": ":443", + "": ":8080", + "oddvalue": ":8080", + } { + if got := portFromAddr(in); got != want { + t.Errorf("portFromAddr(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/pkg/database/migrate/migrate.go b/pkg/database/migrate/migrate.go new file mode 100644 index 0000000..58ef771 --- /dev/null +++ b/pkg/database/migrate/migrate.go @@ -0,0 +1,59 @@ +// Package migrate runs golang-migrate against an embedded migrations FS. +package migrate + +import ( + "embed" + "errors" + "fmt" + "strings" + + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Up applies all pending migrations using the given Postgres URL. +func Up(databaseURL string) error { + return runMigration(databaseURL, func(m *migrate.Migrate) error { return m.Up() }) +} + +// Down rolls back all migrations. Used in tests only. +func Down(databaseURL string) error { + return runMigration(databaseURL, func(m *migrate.Migrate) error { return m.Down() }) +} + +func runMigration(databaseURL string, op func(*migrate.Migrate) error) error { + src, err := iofs.New(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("load migrations: %w", err) + } + m, err := migrate.NewWithSourceInstance("iofs", src, toMigrateURL(databaseURL)) + if err != nil { + return fmt.Errorf("init migrate: %w", err) + } + defer func() { _, _ = m.Close() }() + + if err := op(m); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("apply migrations: %w", err) + } + return nil +} + +// toMigrateURL rewrites a Postgres DSN to the scheme golang-migrate's pgx/v5 +// driver registers under. The pgx/v5 driver registers as "pgx5", so a stock +// "postgres://..." DSN won't be matched. +func toMigrateURL(dsn string) string { + switch { + case strings.HasPrefix(dsn, "postgres://"): + return "pgx5://" + strings.TrimPrefix(dsn, "postgres://") + case strings.HasPrefix(dsn, "postgresql://"): + return "pgx5://" + strings.TrimPrefix(dsn, "postgresql://") + } + return dsn +} + +// Ensure the pgx/v5 driver is registered with golang-migrate. +var _ = pgx.Postgres{} diff --git a/pkg/database/migrate/migrations/0001_init.down.sql b/pkg/database/migrate/migrations/0001_init.down.sql new file mode 100644 index 0000000..d392013 --- /dev/null +++ b/pkg/database/migrate/migrations/0001_init.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS audit_payloads_response_headers_gin; +DROP INDEX IF EXISTS audit_payloads_request_headers_gin; +DROP INDEX IF EXISTS audit_payloads_replayed_from_idx; +DROP TABLE IF EXISTS audit_payloads; +DROP INDEX IF EXISTS audit_events_status_idx; +DROP INDEX IF EXISTS audit_events_session_idx; +DROP INDEX IF EXISTS audit_events_user_idx; +DROP INDEX IF EXISTS audit_events_path_idx; +DROP INDEX IF EXISTS audit_events_route_idx; +DROP INDEX IF EXISTS audit_events_ts_idx; +DROP TABLE IF EXISTS audit_events; +DROP TABLE IF EXISTS api_keys; diff --git a/pkg/database/migrate/migrations/0001_init.up.sql b/pkg/database/migrate/migrations/0001_init.up.sql new file mode 100644 index 0000000..45a0051 --- /dev/null +++ b/pkg/database/migrate/migrations/0001_init.up.sql @@ -0,0 +1,92 @@ +-- Initial schema for api-test. +-- +-- Two-table audit model: audit_events carries the indexable summary; the +-- sibling audit_payloads carries the full HTTP request/response detail +-- (headers, body, query). Splits keep the summary row free of multi-KB +-- JSONB blobs so time/route/identity queries stay fast; the payload join +-- only runs when an operator drills into a single event in the portal. +-- +-- Cascade delete keeps retention cleanup atomic. + +CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + hash TEXT NOT NULL, + description TEXT, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL, + duration_ms BIGINT NOT NULL, + request_id TEXT, + session_id TEXT, + user_subject TEXT, + user_email TEXT, + auth_type TEXT, + api_key_name TEXT, + + -- HTTP request/response surface + method TEXT NOT NULL, + path TEXT NOT NULL, + route_name TEXT, -- "whoami", "sized", ... (group-relative name) + endpoint_group TEXT, -- "identity", "data", ... + status INTEGER NOT NULL, + bytes_in INTEGER NOT NULL DEFAULT 0, + bytes_out INTEGER NOT NULL DEFAULT 0, + + success BOOLEAN NOT NULL, + error_message TEXT, + error_category TEXT, + + remote_addr TEXT, + user_agent TEXT +); + +CREATE INDEX IF NOT EXISTS audit_events_ts_idx ON audit_events (ts DESC); +CREATE INDEX IF NOT EXISTS audit_events_route_idx ON audit_events (route_name, ts DESC); +CREATE INDEX IF NOT EXISTS audit_events_path_idx ON audit_events (path, ts DESC); +CREATE INDEX IF NOT EXISTS audit_events_user_idx ON audit_events (user_subject, ts DESC); +CREATE INDEX IF NOT EXISTS audit_events_session_idx ON audit_events (session_id, ts DESC); +CREATE INDEX IF NOT EXISTS audit_events_status_idx ON audit_events (status, ts DESC); + +CREATE TABLE IF NOT EXISTS audit_payloads ( + event_id TEXT PRIMARY KEY REFERENCES audit_events(id) ON DELETE CASCADE, + + -- Request side + request_headers JSONB, + request_query JSONB, + request_content_type TEXT, + request_body BYTEA, + request_size_bytes INTEGER NOT NULL DEFAULT 0, + request_truncated BOOLEAN NOT NULL DEFAULT false, + request_remote_addr TEXT, + + -- Response side + response_headers JSONB, + response_content_type TEXT, + response_body BYTEA, + response_size_bytes INTEGER NOT NULL DEFAULT 0, + response_truncated BOOLEAN NOT NULL DEFAULT false, + + -- Replay linkage; ON DELETE SET NULL so deleting the original doesn't + -- cascade into the replay's payload row. + replayed_from TEXT REFERENCES audit_events(id) ON DELETE SET NULL, + + captured_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS audit_payloads_replayed_from_idx + ON audit_payloads (replayed_from) + WHERE replayed_from IS NOT NULL; + +-- jsonb_path_ops indexes: smaller and faster than the default GIN for the +-- @> containment operator the portal API uses to filter payload contents. +CREATE INDEX IF NOT EXISTS audit_payloads_request_headers_gin + ON audit_payloads USING gin (request_headers jsonb_path_ops); +CREATE INDEX IF NOT EXISTS audit_payloads_response_headers_gin + ON audit_payloads USING gin (response_headers jsonb_path_ops); diff --git a/pkg/database/pg.go b/pkg/database/pg.go new file mode 100644 index 0000000..24e31df --- /dev/null +++ b/pkg/database/pg.go @@ -0,0 +1,41 @@ +// Package database wires the PostgreSQL connection pool. +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/plexara/api-test/pkg/config" +) + +// Open returns a configured pgxpool.Pool. The caller owns Close(). +func Open(ctx context.Context, cfg config.DatabaseConfig) (*pgxpool.Pool, error) { + pcfg, err := pgxpool.ParseConfig(cfg.URL) + if err != nil { + return nil, fmt.Errorf("parse database url: %w", err) + } + if cfg.MaxOpenConns > 0 { + pcfg.MaxConns = cfg.MaxOpenConns + } + if cfg.MaxIdleConns > 0 { + pcfg.MinConns = cfg.MaxIdleConns + } + if cfg.ConnMaxLifetime > 0 { + pcfg.MaxConnLifetime = cfg.ConnMaxLifetime + } + + pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + pool, err := pgxpool.NewWithConfig(pingCtx, pcfg) + if err != nil { + return nil, fmt.Errorf("connect: %w", err) + } + if err := pool.Ping(pingCtx); err != nil { + pool.Close() + return nil, fmt.Errorf("ping: %w", err) + } + return pool, nil +} diff --git a/pkg/endpoints/data/data.go b/pkg/endpoints/data/data.go new file mode 100644 index 0000000..d86d54e --- /dev/null +++ b/pkg/endpoints/data/data.go @@ -0,0 +1,221 @@ +// Package data provides deterministic-output test endpoints. Same input +// always produces the same output, which lets a gateway test enrichment +// dedup, caching, and size handling against known fixtures. +package data + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "hash/fnv" + "math/rand/v2" + "net/http" + "strconv" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const groupName = "data" + +// Group implements endpoints.Endpoints for the data 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: "fixed", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/fixed/{key}", + Description: "Return a deterministic body derived from the {key} path parameter. Same key, same body.", + ResponseBody: (*FixedResponse)(nil), + }, + { + Name: "sized", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/sized", + Description: "Return exactly ?bytes=N bytes of deterministic content (alphabet repeated). Capped at 32 MiB.", + QueryParams: (*SizedQuery)(nil), + ResponseBody: (*SizedResponse)(nil), + }, + { + Name: "lorem", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/lorem", + Description: "Return ?words=N words of seeded lorem-ipsum text. Same seed reproduces the same output.", + QueryParams: (*LoremQuery)(nil), + ResponseBody: (*LoremResponse)(nil), + }, + } +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/fixed/{key}", mw(http.HandlerFunc(g.fixed))) + mux.Handle("GET /v1/sized", mw(http.HandlerFunc(g.sized))) + mux.Handle("GET /v1/lorem", mw(http.HandlerFunc(g.lorem))) +} + +// FixedResponse is the wire shape of GET /v1/fixed/{key}. +type FixedResponse struct { + Key string `json:"key"` + Hash string `json:"hash"` + Body string `json:"body"` +} + +func (g *Group) fixed(w http.ResponseWriter, r *http.Request) { + key := r.PathValue("key") + sum := sha256.Sum256([]byte(key)) + h := hex.EncodeToString(sum[:]) + writeJSON(w, http.StatusOK, FixedResponse{ + Key: key, + Hash: h, + Body: fmt.Sprintf("fixed[%s]: %s", key, h), + }) +} + +// SizedQuery is the documented query parameters for GET /v1/sized. +type SizedQuery struct { + Bytes int `json:"bytes"` +} + +// SizedResponse is the wire shape of GET /v1/sized. +type SizedResponse struct { + Bytes int `json:"bytes"` + Body string `json:"body"` +} + +const ( + sizedAlphabet = "abcdefghijklmnopqrstuvwxyz" + // sizedMax bounds the result size at 32 MiB. Larger sizes belong on + // the export endpoint group (M4), which streams to the asset store + // instead of allocating in memory. + sizedMax = 32 << 20 +) + +func (g *Group) sized(w http.ResponseWriter, r *http.Request) { + n, err := strconv.Atoi(r.URL.Query().Get("bytes")) + if err != nil || n < 0 { + writeJSONError(w, http.StatusBadRequest, "bytes must be a non-negative integer") + return + } + if n > sizedMax { + writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("bytes %d exceeds max %d", n, sizedMax)) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // Stream the response so we don't allocate the full body up front. + out := struct { + Bytes int `json:"bytes"` + Body string `json:"body"` + }{Bytes: n, Body: ""} + // Preamble: open object + bytes field + body field opening. + _, _ = fmt.Fprintf(w, `{"bytes":%d,"body":"`, n) + if n > 0 { + buf := make([]byte, 4096) + written := 0 + for written < n { + chunk := n - written + if chunk > len(buf) { + chunk = len(buf) + } + for i := 0; i < chunk; i++ { + buf[i] = sizedAlphabet[(written+i)%len(sizedAlphabet)] + } + _, _ = w.Write(buf[:chunk]) + written += chunk + } + } + _, _ = w.Write([]byte(`"}`)) + _ = out // present for godoc; the streaming write above is the actual response +} + +// LoremQuery is the documented query parameters for GET /v1/lorem. +type LoremQuery struct { + Words int `json:"words"` + Seed string `json:"seed,omitempty"` +} + +// LoremResponse is the wire shape of GET /v1/lorem. +type LoremResponse struct { + Words int `json:"words"` + Body string `json:"body"` +} + +// loremDict is a small word bank for fake-Latin generation. +var loremDict = []string{ + "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", + "elit", "sed", "do", "eiusmod", "tempor", "incididunt", "ut", "labore", + "et", "dolore", "magna", "aliqua", "enim", "ad", "minim", "veniam", + "quis", "nostrud", "exercitation", "ullamco", "laboris", "nisi", + "aliquip", "ex", "ea", "commodo", "consequat", "duis", "aute", "irure", + "in", "reprehenderit", "voluptate", "velit", "esse", "cillum", "fugiat", + "nulla", "pariatur", "excepteur", "sint", "occaecat", "cupidatat", + "non", "proident", "sunt", "culpa", "qui", "officia", "deserunt", + "mollit", "anim", "id", "est", "laborum", +} + +func (g *Group) lorem(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + n, _ := strconv.Atoi(q.Get("words")) + if n <= 0 { + n = 50 + } + if n > 5000 { + n = 5000 + } + rng := newRand(q.Get("seed")) + words := make([]string, n) + for i := 0; i < n; i++ { + words[i] = loremDict[rng.IntN(len(loremDict))] + } + if len(words) > 0 { + first := words[0] + words[0] = strings.ToUpper(first[:1]) + first[1:] + } + writeJSON(w, http.StatusOK, LoremResponse{ + Words: n, + Body: strings.Join(words, " ") + ".", + }) +} + +// newRand returns a *rand.Rand seeded deterministically from seed; if seed +// is empty it returns one seeded from a non-deterministic source. +// +// math/rand/v2 is intentional here; these endpoints generate test fixtures +// and must be reproducible from a seed. crypto/rand would be wrong. +func newRand(seed string) *rand.Rand { + if seed == "" { + return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) // #nosec G404 -- non-crypto PRNG; test fixture + } + h := fnv.New64a() + _, _ = h.Write([]byte(seed)) + a := h.Sum64() + h.Reset() + _, _ = h.Write([]byte("salt:" + seed)) + b := h.Sum64() + return rand.New(rand.NewPCG(a, b)) // #nosec G404 -- non-crypto PRNG; test fixture +} + +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 writeJSONError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/pkg/endpoints/data/data_test.go b/pkg/endpoints/data/data_test.go new file mode 100644 index 0000000..ce36406 --- /dev/null +++ b/pkg/endpoints/data/data_test.go @@ -0,0 +1,134 @@ +package data + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newTestMux(t *testing.T) http.Handler { + t.Helper() + mux := http.NewServeMux() + New().Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestFixed_Deterministic(t *testing.T) { + mux := newTestMux(t) + a := doGet(t, mux, "/v1/fixed/hello") + b := doGet(t, mux, "/v1/fixed/hello") + if a != b { + t.Errorf("fixed not deterministic:\n a=%s\n b=%s", a, b) + } + if c := doGet(t, mux, "/v1/fixed/world"); c == a { + t.Error("different keys produced identical body") + } +} + +func TestSized_ExactBytes(t *testing.T) { + mux := newTestMux(t) + for _, n := range []int{0, 1, 26, 27, 1024, 65536} { + body := doGet(t, mux, "/v1/sized?bytes="+itoa(n)) + var resp struct { + Bytes int `json:"bytes"` + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("decode (n=%d): %v", n, err) + } + if resp.Bytes != n { + t.Errorf("bytes field = %d want %d", resp.Bytes, n) + } + if len(resp.Body) != n { + t.Errorf("body length (n=%d): got %d", n, len(resp.Body)) + } + // Determinism check: alphabet repeats from index 0. + if n > 0 && resp.Body[0] != 'a' { + t.Errorf("body (n=%d) does not start with 'a': %q", n, resp.Body[:1]) + } + } +} + +func TestSized_RejectsBadInput(t *testing.T) { + mux := newTestMux(t) + for _, q := range []string{"bytes=abc", "bytes=-1", "bytes=999999999999"} { + req := httptest.NewRequest(http.MethodGet, "/v1/sized?"+q, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("%s: status %d want 400", q, w.Code) + } + } +} + +func TestLorem_SeededReproducible(t *testing.T) { + mux := newTestMux(t) + a := doGet(t, mux, "/v1/lorem?words=20&seed=cat") + b := doGet(t, mux, "/v1/lorem?words=20&seed=cat") + if a != b { + t.Errorf("lorem not deterministic for fixed seed:\n a=%s\n b=%s", a, b) + } + c := doGet(t, mux, "/v1/lorem?words=20&seed=dog") + if c == a { + t.Error("different seeds produced identical body") + } +} + +func TestLorem_DefaultsAndCaps(t *testing.T) { + mux := newTestMux(t) + body := doGet(t, mux, "/v1/lorem?words=0&seed=x") + var resp LoremResponse + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatal(err) + } + if resp.Words != 50 { + t.Errorf("default words = %d want 50", resp.Words) + } + body = doGet(t, mux, "/v1/lorem?words=100000&seed=x") + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatal(err) + } + if resp.Words != 5000 { + t.Errorf("cap = %d want 5000", resp.Words) + } + if !strings.HasSuffix(resp.Body, ".") { + t.Error("lorem body missing trailing period") + } +} + +func doGet(t *testing.T, h http.Handler, path string) string { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code/100 != 2 { + t.Fatalf("%s: status %d body=%s", path, w.Code, w.Body.String()) + } + return w.Body.String() +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/pkg/endpoints/echo/echo.go b/pkg/endpoints/echo/echo.go new file mode 100644 index 0000000..810e739 --- /dev/null +++ b/pkg/endpoints/echo/echo.go @@ -0,0 +1,122 @@ +// Package echo provides a generic catch-all endpoint that returns the +// inbound request verbatim (with auth headers redacted). Useful for +// ad-hoc try-it use from the portal or a curl one-liner against a Plexara +// connection registered for api-test. +package echo + +import ( + "encoding/json" + "io" + "net/http" + "strings" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const groupName = "echo" + +// Group implements endpoints.Endpoints for the echo group. +type Group struct { + redactHeaders []string +} + +// New returns a Group. redactHeaders names headers whose values should be +// replaced with "[redacted]" in the response. +func New(redactHeaders []string) *Group { + rh := make([]string, 0, len(redactHeaders)) + for _, h := range redactHeaders { + rh = append(rh, strings.ToLower(h)) + } + return &Group{redactHeaders: rh} +} + +// Name implements endpoints.Endpoints. +func (Group) Name() string { return groupName } + +// Routes implements endpoints.Endpoints. +// +// We register one entry per supported method so the OpenAPI generator and +// portal display them individually; the underlying handler is shared. +func (Group) Routes() []endpoints.EndpointMeta { + methods := []string{ + http.MethodGet, http.MethodPost, http.MethodPut, + http.MethodPatch, http.MethodDelete, http.MethodHead, + } + out := make([]endpoints.EndpointMeta, 0, len(methods)) + for _, m := range methods { + out = append(out, endpoints.EndpointMeta{ + Name: "echo_" + strings.ToLower(m), + Group: groupName, + Method: m, + Path: "/v1/echo", + Description: "Echo the request (method, path, query, headers, body) back. Sensitive headers redacted.", + ResponseBody: (*Response)(nil), + }) + } + return out +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + for _, m := range []string{ + http.MethodGet, http.MethodPost, http.MethodPut, + http.MethodPatch, http.MethodDelete, http.MethodHead, + } { + mux.Handle(m+" /v1/echo", mw(http.HandlerFunc(g.handle))) + } +} + +// Response is the wire shape of /v1/echo. +type Response struct { + Method string `json:"method"` + Path string `json:"path"` + Query map[string][]string `json:"query,omitempty"` + Headers map[string][]string `json:"headers"` + Body any `json:"body,omitempty"` + BodyRawText string `json:"body_raw_text,omitempty"` + BodySize int `json:"body_size"` +} + +func (g *Group) handle(w http.ResponseWriter, r *http.Request) { + resp := Response{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Headers: make(map[string][]string, len(r.Header)), + } + for k, vs := range r.Header { + if g.shouldRedact(strings.ToLower(k)) { + resp.Headers[k] = []string{"[redacted]"} + continue + } + resp.Headers[k] = append([]string{}, vs...) + } + + if r.Body != nil && r.Method != http.MethodHead { + raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + resp.BodySize = len(raw) + if len(raw) > 0 { + var parsed any + if err := json.Unmarshal(raw, &parsed); err == nil { + resp.Body = parsed + } else { + resp.BodyRawText = string(raw) + } + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _ = json.NewEncoder(w).Encode(resp) +} + +func (g *Group) shouldRedact(headerLower string) bool { + for _, r := range g.redactHeaders { + if strings.Contains(headerLower, r) { + return true + } + } + return false +} diff --git a/pkg/endpoints/echo/echo_test.go b/pkg/endpoints/echo/echo_test.go new file mode 100644 index 0000000..5bbfa07 --- /dev/null +++ b/pkg/endpoints/echo/echo_test.go @@ -0,0 +1,126 @@ +package echo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newTestMux(t *testing.T, redact []string) http.Handler { + t.Helper() + mux := http.NewServeMux() + New(redact).Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestEcho_GETRoundtrip(t *testing.T) { + mux := newTestMux(t, []string{"authorization"}) + req := httptest.NewRequest(http.MethodGet, "/v1/echo?foo=1&foo=2&bar=baz", nil) + req.Header.Set("X-Custom", "v1") + req.Header.Set("Authorization", "Bearer keep-this-private") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var resp Response + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if resp.Method != http.MethodGet { + t.Errorf("method = %q", resp.Method) + } + if v, ok := resp.Query["foo"]; !ok || len(v) != 2 || v[0] != "1" || v[1] != "2" { + t.Errorf("query foo = %v", resp.Query["foo"]) + } + if got := resp.Headers["X-Custom"]; len(got) != 1 || got[0] != "v1" { + t.Errorf("X-Custom = %v", got) + } + if got := resp.Headers["Authorization"]; len(got) != 1 || got[0] != "[redacted]" { + t.Errorf("Authorization not redacted: %v", got) + } + if strings.Contains(w.Body.String(), "keep-this-private") { + t.Error("response leaks Authorization value") + } +} + +func TestEcho_POSTBodyJSON(t *testing.T) { + mux := newTestMux(t, nil) + body := strings.NewReader(`{"a":1,"b":[2,3]}`) + req := httptest.NewRequest(http.MethodPost, "/v1/echo", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var resp Response + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if resp.Method != http.MethodPost { + t.Errorf("method = %q", resp.Method) + } + if resp.BodySize == 0 { + t.Errorf("body_size = 0") + } + m, ok := resp.Body.(map[string]any) + if !ok { + t.Fatalf("body not parsed as object: %T", resp.Body) + } + if m["a"] == nil { + t.Errorf("body missing a: %v", m) + } +} + +func TestEcho_POSTBodyRawText(t *testing.T) { + mux := newTestMux(t, nil) + body := strings.NewReader(`not-json: bytes ; here`) + req := httptest.NewRequest(http.MethodPost, "/v1/echo", body) + req.Header.Set("Content-Type", "text/plain") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var resp Response + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatal(err) + } + if resp.BodyRawText == "" { + t.Error("expected raw text fallback to be populated") + } + if resp.Body != nil { + t.Errorf("body should not parse as JSON, got %v", resp.Body) + } +} + +func TestEcho_HEADHasNoBody(t *testing.T) { + mux := newTestMux(t, nil) + req := httptest.NewRequest(http.MethodHead, "/v1/echo", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("HEAD response carried body of length %d", w.Body.Len()) + } +} + +func TestEcho_AllMethodsRouted(t *testing.T) { + mux := newTestMux(t, nil) + for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"} { + req := httptest.NewRequest(m, "/v1/echo", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("%s: status %d", m, w.Code) + } + } +} diff --git a/pkg/endpoints/failure/failure.go b/pkg/endpoints/failure/failure.go new file mode 100644 index 0000000..a277ccd --- /dev/null +++ b/pkg/endpoints/failure/failure.go @@ -0,0 +1,186 @@ +// Package failure provides test endpoints that produce controlled failure +// modes - error responses, latency, and probabilistic flakiness; so a +// gateway can be exercised against well-defined adversarial inputs. +package failure + +import ( + "encoding/json" + "fmt" + "hash/fnv" + "math/rand/v2" + "net/http" + "strconv" + "time" + + "github.com/plexara/api-test/pkg/endpoints" +) + +const groupName = "failure" + +// Group implements endpoints.Endpoints for the failure 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: "status", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/status/{code}", + Description: "Return the supplied HTTP status code (httpbin-style). Body documents what was returned.", + ResponseBody: (*StatusResponse)(nil), + }, + { + Name: "slow", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/slow", + Description: "Sleep for ?ms=N milliseconds before responding (cap 60s). Honors context cancellation.", + QueryParams: (*SlowQuery)(nil), + ResponseBody: (*SlowResponse)(nil), + }, + { + Name: "flaky", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/flaky", + Description: "Return 200 or 503 based on ?fail_rate=&seed=&call_id=. Same seed+call_id always yields the same outcome.", + QueryParams: (*FlakyQuery)(nil), + ResponseBody: (*FlakyResponse)(nil), + }, + } +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/status/{code}", mw(http.HandlerFunc(g.status))) + mux.Handle("GET /v1/slow", mw(http.HandlerFunc(g.slow))) + mux.Handle("GET /v1/flaky", mw(http.HandlerFunc(g.flaky))) +} + +// StatusResponse is the wire shape of GET /v1/status/{code}. +type StatusResponse struct { + Status int `json:"status"` + Message string `json:"message"` +} + +func (g *Group) status(w http.ResponseWriter, r *http.Request) { + code, err := strconv.Atoi(r.PathValue("code")) + if err != nil || code < 100 || code > 599 { + writeJSONError(w, http.StatusBadRequest, "code must be an integer in [100, 599]") + return + } + writeJSON(w, code, StatusResponse{Status: code, Message: http.StatusText(code)}) +} + +// SlowQuery is the documented query parameters for GET /v1/slow. +type SlowQuery struct { + MS int `json:"ms"` +} + +// SlowResponse is the wire shape of GET /v1/slow. +type SlowResponse struct { + SleptMS int64 `json:"slept_ms"` + Cancelled bool `json:"cancelled,omitempty"` + RequestedM int `json:"requested_ms"` +} + +func (g *Group) slow(w http.ResponseWriter, r *http.Request) { + ms, _ := strconv.Atoi(r.URL.Query().Get("ms")) + if ms < 0 { + ms = 0 + } + if ms > 60_000 { + ms = 60_000 + } + start := time.Now() + timer := time.NewTimer(time.Duration(ms) * time.Millisecond) + defer timer.Stop() + select { + case <-timer.C: + writeJSON(w, http.StatusOK, SlowResponse{ + SleptMS: time.Since(start).Milliseconds(), + RequestedM: ms, + }) + case <-r.Context().Done(): + // Best-effort: write the cancellation note. The client may already + // be gone; the audit middleware records the partial state. + writeJSON(w, 499, SlowResponse{ + SleptMS: time.Since(start).Milliseconds(), + Cancelled: true, + RequestedM: ms, + }) + } +} + +// FlakyQuery is the documented query parameters for GET /v1/flaky. +type FlakyQuery struct { + FailRate float64 `json:"fail_rate"` + Seed string `json:"seed,omitempty"` + CallID int `json:"call_id,omitempty"` +} + +// FlakyResponse is the wire shape of GET /v1/flaky. +type FlakyResponse struct { + Failed bool `json:"failed"` + Roll float64 `json:"roll"` + FailRate float64 `json:"fail_rate"` +} + +func (g *Group) flaky(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + rate, _ := strconv.ParseFloat(q.Get("fail_rate"), 64) + if rate < 0 { + rate = 0 + } + if rate > 1 { + rate = 1 + } + callID, _ := strconv.Atoi(q.Get("call_id")) + rng := flakyRand(q.Get("seed"), callID) + roll := rng.Float64() + if roll < rate { + writeJSON(w, http.StatusServiceUnavailable, FlakyResponse{ + Failed: true, Roll: roll, FailRate: rate, + }) + return + } + writeJSON(w, http.StatusOK, FlakyResponse{ + Failed: false, Roll: roll, FailRate: rate, + }) +} + +// flakyRand returns a *rand.Rand seeded by (seed, callID) so failures are +// reproducible across runs. math/rand/v2 is intentional; this is a test +// fixture, not a security primitive. +func flakyRand(seed string, callID int) *rand.Rand { + if seed == "" { + return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64())) // #nosec G404 -- non-crypto PRNG; test fixture + } + h := fnv.New64a() + _, _ = h.Write([]byte(seed)) + _, _ = fmt.Fprintf(h, "|%d", callID) + a := h.Sum64() + h.Reset() + _, _ = h.Write([]byte("salt|" + seed)) + _, _ = fmt.Fprintf(h, "|%d", callID) + b := h.Sum64() + return rand.New(rand.NewPCG(a, b)) // #nosec G404 -- non-crypto PRNG; test fixture +} + +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 writeJSONError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/pkg/endpoints/failure/failure_test.go b/pkg/endpoints/failure/failure_test.go new file mode 100644 index 0000000..ead0bdd --- /dev/null +++ b/pkg/endpoints/failure/failure_test.go @@ -0,0 +1,133 @@ +package failure + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newTestMux(t *testing.T) http.Handler { + t.Helper() + mux := http.NewServeMux() + New().Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestStatus_Passthrough(t *testing.T) { + mux := newTestMux(t) + for _, code := range []int{200, 204, 301, 400, 404, 418, 500, 503} { + req := httptest.NewRequest(http.MethodGet, "/v1/status/"+itoa(code), nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != code { + t.Errorf("status %d: got %d", code, w.Code) + } + } +} + +func TestStatus_Invalid(t *testing.T) { + mux := newTestMux(t) + for _, c := range []string{"abc", "99", "600", "-1"} { + req := httptest.NewRequest(http.MethodGet, "/v1/status/"+c, nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("%s: status %d want 400", c, w.Code) + } + } +} + +func TestSlow_HonorsContextCancel(t *testing.T) { + mux := newTestMux(t) + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/v1/slow?ms=5000", nil).WithContext(ctx) + w := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + mux.ServeHTTP(w, req) + close(done) + }() + time.Sleep(50 * time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("slow handler did not return after cancel") + } + // Should be the 499 client-cancellation response. + if w.Code != 499 { + t.Errorf("status %d want 499", w.Code) + } +} + +func TestSlow_FastPath(t *testing.T) { + mux := newTestMux(t) + req := httptest.NewRequest(http.MethodGet, "/v1/slow?ms=1", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var resp SlowResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.RequestedM != 1 { + t.Errorf("requested_ms = %d want 1", resp.RequestedM) + } +} + +func TestFlaky_Reproducible(t *testing.T) { + mux := newTestMux(t) + a := doStatus(t, mux, "/v1/flaky?fail_rate=0.5&seed=abc&call_id=7") + b := doStatus(t, mux, "/v1/flaky?fail_rate=0.5&seed=abc&call_id=7") + if a != b { + t.Errorf("flaky not reproducible: %d vs %d", a, b) + } +} + +func TestFlaky_RateBounds(t *testing.T) { + mux := newTestMux(t) + if c := doStatus(t, mux, "/v1/flaky?fail_rate=0&seed=x&call_id=1"); c != 200 { + t.Errorf("rate=0 should pass: status %d", c) + } + if c := doStatus(t, mux, "/v1/flaky?fail_rate=1&seed=x&call_id=1"); c != 503 { + t.Errorf("rate=1 should fail: status %d", c) + } +} + +func doStatus(t *testing.T, h http.Handler, path string) int { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w.Code +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/pkg/endpoints/identity/identity.go b/pkg/endpoints/identity/identity.go new file mode 100644 index 0000000..609cff1 --- /dev/null +++ b/pkg/endpoints/identity/identity.go @@ -0,0 +1,129 @@ +// Package identity contains test endpoints that surface inbound auth identity +// and HTTP headers. The bread-and-butter of verifying an API gateway's +// pass-through behavior. +// +// Generic request echo lives in pkg/endpoints/echo so this package can stay +// focused on identity/header inspection. +package identity + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/plexara/api-test/pkg/auth/inbound" + "github.com/plexara/api-test/pkg/endpoints" +) + +const groupName = "identity" + +// Group implements endpoints.Endpoints for the identity group. +type Group struct { + redactHeaders []string +} + +// New returns a Group. redactHeaders names headers whose values should be +// replaced with "[redacted]" by the headers endpoint. +func New(redactHeaders []string) *Group { + rh := make([]string, 0, len(redactHeaders)) + for _, h := range redactHeaders { + rh = append(rh, strings.ToLower(h)) + } + return &Group{redactHeaders: rh} +} + +// Name implements endpoints.Endpoints. +func (Group) Name() string { return groupName } + +// Routes implements endpoints.Endpoints. +func (Group) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{ + { + Name: "whoami", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/whoami", + Description: "Return the resolved inbound auth identity (mode, key id, subject, scopes).", + AuthRequired: false, + ResponseBody: (*WhoamiResponse)(nil), + }, + { + Name: "headers", + Group: groupName, + Method: http.MethodGet, + Path: "/v1/headers", + Description: "Return inbound HTTP headers, with sensitive values redacted.", + AuthRequired: false, + ResponseBody: (*HeadersResponse)(nil), + }, + } +} + +// Mount implements endpoints.Endpoints. +func (g *Group) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/whoami", mw(http.HandlerFunc(g.whoami))) + mux.Handle("GET /v1/headers", mw(http.HandlerFunc(g.headers))) +} + +// WhoamiResponse is the wire shape of GET /v1/whoami. +// +// Reads the resolved inbound.Identity off the request context (set by +// the inbound auth middleware in pkg/httpmw). When no identity is +// present (e.g. tests bypassing the middleware) it reports anonymous. +type WhoamiResponse struct { + Subject string `json:"subject"` + Email string `json:"email,omitempty"` + AuthType string `json:"auth_type"` + KeyName string `json:"key_name,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Claims map[string]any `json:"claims,omitempty"` +} + +func (g *Group) whoami(w http.ResponseWriter, r *http.Request) { + id := inbound.FromContext(r.Context()) + if id == nil { + writeJSON(w, http.StatusOK, WhoamiResponse{AuthType: "anonymous"}) + return + } + writeJSON(w, http.StatusOK, WhoamiResponse{ + Subject: id.Subject, + Email: id.Email, + AuthType: id.AuthType, + KeyName: id.KeyName, + Scopes: id.Scopes, + Claims: id.Claims, + }) +} + +// HeadersResponse is the wire shape of GET /v1/headers. +type HeadersResponse struct { + Headers map[string][]string `json:"headers"` + Count int `json:"count"` +} + +func (g *Group) headers(w http.ResponseWriter, r *http.Request) { + out := make(map[string][]string, len(r.Header)) + for k, vs := range r.Header { + if g.shouldRedact(strings.ToLower(k)) { + out[k] = []string{"[redacted]"} + continue + } + out[k] = append([]string{}, vs...) + } + writeJSON(w, http.StatusOK, HeadersResponse{Headers: out, Count: len(out)}) +} + +func (g *Group) shouldRedact(headerLower string) bool { + for _, r := range g.redactHeaders { + if strings.Contains(headerLower, r) { + return true + } + } + return false +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/pkg/endpoints/identity/identity_test.go b/pkg/endpoints/identity/identity_test.go new file mode 100644 index 0000000..14708a6 --- /dev/null +++ b/pkg/endpoints/identity/identity_test.go @@ -0,0 +1,85 @@ +package identity + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +func newTestMux(t *testing.T, redact []string) http.Handler { + t.Helper() + mux := http.NewServeMux() + New(redact).Mount(mux, endpoints.PassthroughMiddleware) + return mux +} + +func TestWhoami_Anonymous(t *testing.T) { + mux := newTestMux(t, nil) + req := httptest.NewRequest(http.MethodGet, "/v1/whoami", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var body WhoamiResponse + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.AuthType != "anonymous" { + t.Errorf("auth_type = %q want anonymous", body.AuthType) + } +} + +func TestHeaders_RedactsConfigured(t *testing.T) { + mux := newTestMux(t, []string{"authorization", "x-api-key", "cookie"}) + + req := httptest.NewRequest(http.MethodGet, "/v1/headers", nil) + req.Header.Set("Authorization", "Bearer secret-token-123") + req.Header.Set("X-API-Key", "key-abc") + req.Header.Set("Cookie", "session=xyz") + req.Header.Set("X-Trace-Id", "trace-789") + req.Header.Add("Accept-Language", "en-US") + req.Header.Add("Accept-Language", "fr-FR") + + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var body HeadersResponse + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatal(err) + } + + checkRedacted := func(name string) { + t.Helper() + v, ok := body.Headers[name] + if !ok { + t.Errorf("missing header %q", name) + return + } + if len(v) != 1 || v[0] != "[redacted]" { + t.Errorf("%s not redacted: %v", name, v) + } + } + checkRedacted("Authorization") + checkRedacted("X-Api-Key") // canonicalized by net/http + checkRedacted("Cookie") + + if v := body.Headers["X-Trace-Id"]; len(v) != 1 || v[0] != "trace-789" { + t.Errorf("X-Trace-Id altered: %v", v) + } + if v := body.Headers["Accept-Language"]; len(v) != 2 { + t.Errorf("Accept-Language not preserved: %v", v) + } + // Sanity: response must not contain the secret value verbatim. + if strings.Contains(w.Body.String(), "secret-token-123") { + t.Error("response leaks secret token") + } +} diff --git a/pkg/endpoints/registry.go b/pkg/endpoints/registry.go new file mode 100644 index 0000000..242413a --- /dev/null +++ b/pkg/endpoints/registry.go @@ -0,0 +1,163 @@ +// Package endpoints defines the Endpoints interface and shared metadata used +// by the portal to render endpoint catalogs and Try-It forms, and by the +// in-tree OpenAPI generator (pkg/oapi) to build the served spec. +package endpoints + +import ( + "net/http" + "strings" +) + +// Endpoints is the contract every group of test endpoints implements. +// +// Mount mounts the group's HTTP routes onto the given mux. The Middleware +// argument lets the composition layer wrap each handler with audit/identity +// middleware without each group having to know about it. +type Endpoints interface { + Name() string + Routes() []EndpointMeta + Mount(mux *http.ServeMux, mw Middleware) +} + +// Middleware wraps an http.Handler. The composition layer supplies the audit +// and identity middleware here so every endpoint group records consistently. +// +// Groups call mw to wrap their handlers before mux.Handle, e.g.: +// +// mux.Handle("GET /v1/whoami", mw(http.HandlerFunc(whoami))) +type Middleware func(http.Handler) http.Handler + +// PassthroughMiddleware is the no-op identity middleware. Used by tests and +// during M1 when audit/identity middleware isn't wired yet. +var PassthroughMiddleware Middleware = func(h http.Handler) http.Handler { return h } + +// EndpointMeta is the portal- and OpenAPI-friendly description of one route. +// +// PathParams, QueryParams, RequestBody, ResponseBody are nil-able example +// shapes used by the OpenAPI generator's reflection step. Groups should set +// the matching field with a typed zero value (e.g. (*sizedInput)(nil)) so +// reflection picks up the field tags without instantiating data. +type EndpointMeta struct { + Name string `json:"name"` + Group string `json:"group"` + Method string `json:"method"` + Path string `json:"path"` + Description string `json:"description"` + AuthRequired bool `json:"auth_required"` + PathParams any `json:"-"` + QueryParams any `json:"-"` + RequestBody any `json:"-"` + ResponseBody any `json:"-"` +} + +// Registry collects endpoint groups for portal listing, OpenAPI generation, +// and audit dispatch (route + group resolution from a live request). +type Registry struct { + groups []Endpoints + // flat is the materialized (group, route) pairs in registration + // order. The audit middleware walks it to resolve a request's + // matched template, since path-parameterized routes like + // /v1/fixed/{key} don't equal /v1/fixed/abc under literal lookup. + flat []routeEntry +} + +type routeEntry struct { + group string + meta EndpointMeta + segs []string +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{} +} + +// Add appends a group and indexes its routes for pattern matching. +func (r *Registry) Add(g Endpoints) { + r.groups = append(r.groups, g) + for _, route := range g.Routes() { + r.flat = append(r.flat, routeEntry{ + group: g.Name(), + meta: route, + segs: splitPathSegments(route.Path), + }) + } +} + +// Groups returns the registered groups in registration order. +func (r *Registry) Groups() []Endpoints { return r.groups } + +// RouteForRequest resolves a live (method, requestPath) to the matched +// route's (group, name). Returns ("", "") when no registered route +// matches. Used by the audit middleware to populate endpoint_group and +// route_name on the event row. +// +// Matching mirrors Go 1.22+ http.ServeMux pattern semantics for the +// shapes this project actually uses: literal segments must match +// exactly, {name} segments match any single segment, and the path's +// segment count must match. Cross-segment wildcards ({name...}) are not +// used by api-test routes today and are not supported here; if a future +// route needs them, fall through to the mux's own resolver. +func (r *Registry) RouteForRequest(method, requestPath string) (group, name string) { + reqSegs := splitPathSegments(requestPath) + for _, e := range r.flat { + if e.meta.Method != method { + continue + } + if matchSegments(e.segs, reqSegs) { + return e.group, e.meta.Name + } + } + return "", "" +} + +// All returns a flat list of every route's metadata across all groups. +func (r *Registry) All() []EndpointMeta { + var out []EndpointMeta + for _, g := range r.groups { + out = append(out, g.Routes()...) + } + return out +} + +// Mount mounts every group's routes onto mux, wrapped with mw. +func (r *Registry) Mount(mux *http.ServeMux, mw Middleware) { + for _, g := range r.groups { + g.Mount(mux, mw) + } +} + +// splitPathSegments returns the path's "/"-separated segments with empty +// segments dropped. "/v1/fixed/{key}" → ["v1", "fixed", "{key}"]. +func splitPathSegments(p string) []string { + if p == "" { + return nil + } + parts := strings.Split(p, "/") + out := parts[:0] + for _, seg := range parts { + if seg == "" { + continue + } + out = append(out, seg) + } + return out +} + +// matchSegments reports whether requestSegs satisfies the patternSegs +// from a registered route. {name}-style pattern segments match any +// single literal segment. +func matchSegments(patternSegs, requestSegs []string) bool { + if len(patternSegs) != len(requestSegs) { + return false + } + for i, p := range patternSegs { + if len(p) >= 2 && p[0] == '{' && p[len(p)-1] == '}' { + continue + } + if p != requestSegs[i] { + return false + } + } + return true +} diff --git a/pkg/endpoints/registry_test.go b/pkg/endpoints/registry_test.go new file mode 100644 index 0000000..bbfa223 --- /dev/null +++ b/pkg/endpoints/registry_test.go @@ -0,0 +1,132 @@ +package endpoints + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +type stubGroup struct { + name string + routes []EndpointMeta +} + +func (g *stubGroup) Name() string { return g.name } +func (g *stubGroup) Routes() []EndpointMeta { return g.routes } +func (g *stubGroup) Mount(mux *http.ServeMux, mw Middleware) { + for _, r := range g.routes { + r := r + mux.Handle(r.Method+" "+r.Path, mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(r.Name)) + }))) + } +} + +func TestRegistry_AddIndexAndAll(t *testing.T) { + g := &stubGroup{ + name: "g1", + routes: []EndpointMeta{ + {Name: "a", Method: "GET", Path: "/a"}, + {Name: "b", Method: "POST", Path: "/b"}, + }, + } + r := NewRegistry() + r.Add(g) + + if group, name := r.RouteForRequest("GET", "/a"); group != "g1" || name != "a" { + t.Errorf("RouteForRequest(GET /a) = %q,%q, want g1,a", group, name) + } + if group, _ := r.RouteForRequest("DELETE", "/a"); group != "" { + t.Errorf("RouteForRequest(DELETE /a) = %q, want \"\"", group) + } + if all := r.All(); len(all) != 2 { + t.Errorf("All() = %d, want 2", len(all)) + } + if groups := r.Groups(); len(groups) != 1 { + t.Errorf("Groups() = %d, want 1", len(groups)) + } +} + +func TestRegistry_RouteForRequest_PathParams(t *testing.T) { + r := NewRegistry() + r.Add(&stubGroup{ + name: "data", + routes: []EndpointMeta{ + {Name: "fixed", Method: "GET", Path: "/v1/fixed/{key}"}, + {Name: "sized", Method: "GET", Path: "/v1/sized"}, + }, + }) + r.Add(&stubGroup{ + name: "failure", + routes: []EndpointMeta{ + {Name: "status", Method: "GET", Path: "/v1/status/{code}"}, + }, + }) + + cases := []struct { + method, path string + wantGroup string + wantName string + }{ + {"GET", "/v1/fixed/abc", "data", "fixed"}, + {"GET", "/v1/fixed/anything-here", "data", "fixed"}, + {"GET", "/v1/status/503", "failure", "status"}, + {"GET", "/v1/status/abc", "failure", "status"}, // path matches; handler validates + {"GET", "/v1/sized", "data", "sized"}, // literal still works + {"GET", "/v1/fixed", "", ""}, // missing the {key} segment + {"GET", "/v1/fixed/abc/extra", "", ""}, // extra segment + {"POST", "/v1/fixed/abc", "", ""}, // wrong method + {"GET", "/no-such-thing", "", ""}, + } + for _, tc := range cases { + gotGroup, gotName := r.RouteForRequest(tc.method, tc.path) + if gotGroup != tc.wantGroup || gotName != tc.wantName { + t.Errorf("RouteForRequest(%s %s) = (%q,%q), want (%q,%q)", + tc.method, tc.path, gotGroup, gotName, tc.wantGroup, tc.wantName) + } + } +} + +func TestRegistry_MountWrapsWithMiddleware(t *testing.T) { + calls := 0 + mw := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + next.ServeHTTP(w, r) + }) + } + g := &stubGroup{ + name: "g", + routes: []EndpointMeta{ + {Name: "x", Method: "GET", Path: "/x"}, + }, + } + r := NewRegistry() + r.Add(g) + + mux := http.NewServeMux() + r.Mount(mux, mw) + + srv := httptest.NewServer(mux) + defer srv.Close() + resp, err := http.Get(srv.URL + "/x") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if calls != 1 { + t.Errorf("middleware not invoked: calls=%d", calls) + } +} + +func TestPassthroughMiddleware(t *testing.T) { + called := false + h := PassthroughMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "/", nil)) + if !called { + t.Error("passthrough middleware did not invoke next") + } +} diff --git a/pkg/httpmw/audit.go b/pkg/httpmw/audit.go new file mode 100644 index 0000000..d55bff2 --- /dev/null +++ b/pkg/httpmw/audit.go @@ -0,0 +1,220 @@ +package httpmw + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "time" + + "github.com/plexara/api-test/pkg/audit" + "github.com/plexara/api-test/pkg/auth/inbound" + "github.com/plexara/api-test/pkg/endpoints" +) + +// AuditOptions tunes the audit middleware. +type AuditOptions struct { + // CapturePayloads enables writing the audit_payloads sibling row. + // Default true; set false when only the indexable summary is wanted. + CapturePayloads bool + + // CaptureHeaders includes request and response headers in the + // payload row. Default true. + CaptureHeaders bool + + // MaxPayloadBytes caps per-side body capture. Bodies exceeding the + // cap are truncated and the matching truncated flag is set. + // Default 1 MiB. + MaxPayloadBytes int + + // RedactKeys lists case-insensitive substrings that, when matched + // against a header name or query param key, replace the value with + // "[redacted]" before persisting to the payload row. + RedactKeys []string +} + +func (o AuditOptions) withDefaults() AuditOptions { + if o.MaxPayloadBytes == 0 { + o.MaxPayloadBytes = 1 << 20 + } + return o +} + +// Audit returns middleware that records an audit.Event (and optional +// Payload) for each request. Uses the registry to derive the route name +// + endpoint group for the event row. +// +// Body capture: request body is read up to MaxPayloadBytes via a +// teeReader so the downstream handler still sees the full body. +// Response body is captured by a buffering ResponseWriter wrapper. Both +// sides flag truncation when the cap is reached. +// +// The Logger interface only requires Log; implementations that want +// to broadcast (AsyncLogger live-tail in M3) are free to do so internally. +func Audit(logger audit.Logger, registry *endpoints.Registry, slogger *slog.Logger, opts AuditOptions) func(http.Handler) http.Handler { + opts = opts.withDefaults() + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + ev := audit.NewEvent(r.Method, r.URL.Path) + ev.RequestID = RequestIDFromContext(r.Context()) + ev.RemoteAddr = r.RemoteAddr + ev.UserAgent = r.UserAgent() + if id := inbound.FromContext(r.Context()); id != nil { + ev.UserSubject = id.Subject + ev.UserEmail = id.Email + ev.AuthType = id.AuthType + ev.APIKeyName = id.KeyName + } + if registry != nil { + ev.EndpointGroup, ev.RouteName = registry.RouteForRequest(r.Method, r.URL.Path) + } + + // --- Request capture --- + var ( + reqBodyBuf bytes.Buffer + reqBodyCap = opts.MaxPayloadBytes + reqOversize bool + ) + if r.Body != nil && opts.CapturePayloads { + // teeReader so the handler still gets the full body. + r.Body = readCloserTee{r: r.Body, w: capWriter{buf: &reqBodyBuf, max: reqBodyCap, oversize: &reqOversize}} + } + + // --- Response capture --- + rec := newAuditRecorder(w, opts.MaxPayloadBytes, opts.CapturePayloads) + next.ServeHTTP(rec, r) + + ev.Status = rec.status + ev.BytesIn = reqBodyBuf.Len() + if reqOversize { + ev.BytesIn = reqBodyCap // approximate; we know we capped + } + ev.BytesOut = rec.bytesTotal + ev.DurationMS = time.Since(start).Milliseconds() + ev.Success = rec.status >= 200 && rec.status < 400 + + if opts.CapturePayloads { + p := &audit.Payload{ + RequestSizeBytes: reqBodyBuf.Len(), + RequestTruncated: reqOversize, + RequestRemoteAddr: r.RemoteAddr, + RequestContentType: r.Header.Get("Content-Type"), + RequestBody: reqBodyBuf.Bytes(), + ResponseSizeBytes: rec.bytesTotal, + ResponseTruncated: rec.truncated, + ResponseContentType: rec.Header().Get("Content-Type"), + ResponseBody: rec.body.Bytes(), + } + if opts.CaptureHeaders { + p.RequestHeaders = audit.SanitizeHeaders(r.Header, opts.RedactKeys) + p.RequestQuery = audit.SanitizeQuery(r.URL.Query(), opts.RedactKeys) + p.ResponseHeaders = audit.SanitizeHeaders(rec.Header(), opts.RedactKeys) + } + ev.Payload = p + } + + // Log uses a fresh background context so a client disconnect + // (request ctx cancelled) doesn't strand the audit write. + if err := logger.Log(context.Background(), *ev); err != nil { + slogger.Warn("audit log failed", "err", err, "path", ev.Path) + } + }) + } +} + +// auditRecorder wraps http.ResponseWriter to capture the status code, a +// truncated body buffer, and the total response byte count. +type auditRecorder struct { + http.ResponseWriter + status int + wroteHeader bool + + body bytes.Buffer + max int + capture bool + truncated bool + bytesTotal int +} + +func newAuditRecorder(w http.ResponseWriter, maxBytes int, capture bool) *auditRecorder { + return &auditRecorder{ResponseWriter: w, status: http.StatusOK, max: maxBytes, capture: capture} +} + +func (a *auditRecorder) WriteHeader(code int) { + if a.wroteHeader { + return + } + a.wroteHeader = true + a.status = code + a.ResponseWriter.WriteHeader(code) +} + +func (a *auditRecorder) Write(b []byte) (int, error) { + if !a.wroteHeader { + a.WriteHeader(http.StatusOK) + } + if a.capture { + remaining := a.max - a.body.Len() + captured := 0 + if remaining > 0 { + captured = remaining + if captured > len(b) { + captured = len(b) + } + a.body.Write(b[:captured]) + } + if captured < len(b) { + a.truncated = true + } + } + n, err := a.ResponseWriter.Write(b) + a.bytesTotal += n + return n, err +} + +// readCloserTee is an io.ReadCloser that mirrors reads into w (best-effort, +// errors ignored) so the audit middleware can capture inbound bodies +// without disturbing the downstream handler. +type readCloserTee struct { + r io.ReadCloser + w io.Writer +} + +func (t readCloserTee) Read(p []byte) (int, error) { + n, err := t.r.Read(p) + if n > 0 { + _, _ = t.w.Write(p[:n]) + } + return n, err +} + +func (t readCloserTee) Close() error { return t.r.Close() } + +// capWriter writes up to max bytes into buf, then drops further writes +// and sets *oversize true. The tee always reports the input size so the +// underlying reader keeps draining. +type capWriter struct { + buf *bytes.Buffer + max int + oversize *bool +} + +func (c capWriter) Write(p []byte) (int, error) { + remaining := c.max - c.buf.Len() + if remaining <= 0 { + *c.oversize = true + return len(p), nil + } + n := remaining + if n > len(p) { + n = len(p) + } + c.buf.Write(p[:n]) + if n < len(p) { + *c.oversize = true + } + return len(p), nil +} diff --git a/pkg/httpmw/identity.go b/pkg/httpmw/identity.go new file mode 100644 index 0000000..38b8d0d --- /dev/null +++ b/pkg/httpmw/identity.go @@ -0,0 +1,56 @@ +package httpmw + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + + "github.com/plexara/api-test/pkg/auth/inbound" +) + +// Identity returns middleware that runs the inbound auth chain and +// either: +// - attaches the resolved Identity to the context and proceeds, or +// - responds 401 with a JSON error envelope when the chain rejects. +// +// Anonymous fallback is the chain's responsibility (controlled by +// auth.allow_anonymous in config); this middleware just enforces the +// chain's verdict. +// +// The "401 includes WWW-Authenticate" RFC 6750 convention is honored: +// requests that came in without a credential get a Bearer challenge. +func Identity(chain *inbound.Chain, logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id, err := chain.Authenticate(r.Context(), r) + if err != nil { + if errors.Is(err, inbound.ErrNoCredential) { + w.Header().Set("WWW-Authenticate", `Bearer realm="api-test"`) + writeJSONError(w, http.StatusUnauthorized, "missing credential") + return + } + if errors.Is(err, inbound.ErrInvalidCredential) { + w.Header().Set("WWW-Authenticate", `Bearer realm="api-test", error="invalid_token"`) + writeJSONError(w, http.StatusUnauthorized, "invalid credential") + return + } + logger.Warn("auth chain error", "err", err, "path", r.URL.Path) + writeJSONError(w, http.StatusInternalServerError, "auth error") + return + } + ctx := inbound.WithIdentity(r.Context(), id) + // Mirror into the per-request holder seeded by RequestID so + // AccessLog (which wraps the mux from outside) can read the + // resolved identity after the inner handler returns. + recordIdentity(ctx, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/pkg/httpmw/logger.go b/pkg/httpmw/logger.go new file mode 100644 index 0000000..65cce5a --- /dev/null +++ b/pkg/httpmw/logger.go @@ -0,0 +1,70 @@ +package httpmw + +import ( + "log/slog" + "net/http" + "time" +) + +// AccessLog returns middleware that emits a structured info-level log +// line per request. Captures the status the response writer ultimately +// wrote (via statusRecorder) and reads the resolved identity from the +// per-request holder seeded by RequestID — `r.WithContext(...)` inside +// the per-route Identity middleware doesn't flow back up here, so this +// path can't use inbound.FromContext directly. +func AccessLog(logger *slog.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := newStatusRecorder(w) + next.ServeHTTP(rec, r) + + fields := []any{ + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "bytes", rec.bytes, + "duration_ms", time.Since(start).Milliseconds(), + "request_id", RequestIDFromContext(r.Context()), + } + if id := resolvedIdentity(r.Context()); id != nil { + fields = append(fields, + "auth_type", id.AuthType, + "subject", id.Subject, + ) + } + logger.Info("request", fields...) + }) + } +} + +// statusRecorder wraps http.ResponseWriter to capture the status code and +// response byte count for logging. +type statusRecorder struct { + http.ResponseWriter + status int + bytes int + wroteHeader bool +} + +func newStatusRecorder(w http.ResponseWriter) *statusRecorder { + return &statusRecorder{ResponseWriter: w, status: http.StatusOK} +} + +func (s *statusRecorder) WriteHeader(code int) { + if s.wroteHeader { + return + } + s.wroteHeader = true + s.status = code + s.ResponseWriter.WriteHeader(code) +} + +func (s *statusRecorder) Write(b []byte) (int, error) { + if !s.wroteHeader { + s.WriteHeader(http.StatusOK) + } + n, err := s.ResponseWriter.Write(b) + s.bytes += n + return n, err +} diff --git a/pkg/httpmw/middleware_test.go b/pkg/httpmw/middleware_test.go new file mode 100644 index 0000000..948aba6 --- /dev/null +++ b/pkg/httpmw/middleware_test.go @@ -0,0 +1,351 @@ +package httpmw + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/plexara/api-test/pkg/audit" + "github.com/plexara/api-test/pkg/auth/inbound" + "github.com/plexara/api-test/pkg/config" + "github.com/plexara/api-test/pkg/endpoints" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// --- RequestID --- + +func TestRequestID_PreservesInbound(t *testing.T) { + var got string + h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = RequestIDFromContext(r.Context()) + })) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set(HeaderRequestID, "abc-123") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if got != "abc-123" { + t.Errorf("got %q want abc-123", got) + } + if w.Header().Get(HeaderRequestID) != "abc-123" { + t.Errorf("response header not echoed") + } +} + +func TestRequestID_GeneratesNew(t *testing.T) { + var got string + h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + got = RequestIDFromContext(r.Context()) + })) + r := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if got == "" { + t.Error("no request id generated") + } + if w.Header().Get(HeaderRequestID) != got { + t.Error("response header doesn't match context value") + } +} + +// --- Identity --- + +func TestIdentity_401WhenNoCredAndNoAnonymous(t *testing.T) { + chain := inbound.NewChain(false, inbound.NewBearer([]config.FileBearerToken{{Name: "x", Token: "t"}})) + h := Identity(chain, discardLogger())(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("handler should not be reached") + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if w.Code != http.StatusUnauthorized { + t.Errorf("status %d want 401", w.Code) + } + if !strings.Contains(w.Header().Get("WWW-Authenticate"), "Bearer") { + t.Errorf("WWW-Authenticate missing: %q", w.Header().Get("WWW-Authenticate")) + } +} + +func TestIdentity_AnonymousFallthrough(t *testing.T) { + chain := inbound.NewChain(true) + var saw *inbound.Identity + h := Identity(chain, discardLogger())(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + saw = inbound.FromContext(r.Context()) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if w.Code != http.StatusOK { + t.Errorf("status %d", w.Code) + } + if saw == nil || saw.AuthType != "anonymous" { + t.Errorf("identity = %+v", saw) + } +} + +func TestIdentity_401OnInvalid(t *testing.T) { + chain := inbound.NewChain(true, inbound.NewBearer([]config.FileBearerToken{{Name: "x", Token: "good"}})) + h := Identity(chain, discardLogger())(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("handler should not be reached") + })) + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer bad") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status %d", w.Code) + } + if !strings.Contains(w.Header().Get("WWW-Authenticate"), `error="invalid_token"`) { + t.Errorf("WWW-Authenticate missing invalid_token: %q", w.Header().Get("WWW-Authenticate")) + } +} + +// --- Audit --- + +func TestAudit_WritesEventAndRedactsHeaders(t *testing.T) { + ml := audit.NewMemoryLogger() + registry := endpoints.NewRegistry() + + h := Audit(ml, registry, discardLogger(), AuditOptions{ + CapturePayloads: true, + CaptureHeaders: true, + MaxPayloadBytes: 1024, + // Both "api_key" (underscore — matches the query param) and "api-key" + // (dash — matches the X-API-Key header) are needed; matchesRedactKey + // is exact substring, not regex. The default config carries both. + RedactKeys: []string{"authorization", "api-key", "api_key"}, + })(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + + r := httptest.NewRequest(http.MethodGet, "/v1/test?api_key=keep_secret", strings.NewReader("")) + r.Header.Set("Authorization", "Bearer secret-tok") + r.Header.Set("X-Trace-Id", "trace-1") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + snap := ml.Snapshot() + if len(snap) != 1 { + t.Fatalf("snapshot len = %d", len(snap)) + } + ev := snap[0] + if ev.Method != "GET" || ev.Path != "/v1/test" { + t.Errorf("method/path = %s %s", ev.Method, ev.Path) + } + if ev.Status != http.StatusOK { + t.Errorf("status = %d", ev.Status) + } + if !ev.Success { + t.Error("success should be true for 200") + } + if ev.Payload == nil { + t.Fatal("payload nil") + } + if v := ev.Payload.RequestHeaders["Authorization"]; len(v) != 1 || v[0] != "[redacted]" { + t.Errorf("Authorization not redacted: %v", v) + } + if v := ev.Payload.RequestQuery["api_key"]; len(v) != 1 || v[0] != "[redacted]" { + t.Errorf("api_key query not redacted: %v", v) + } + if string(ev.Payload.ResponseBody) != `{"ok":true}` { + t.Errorf("response body = %q", string(ev.Payload.ResponseBody)) + } + if ev.Payload.ResponseContentType != "application/json" { + t.Errorf("response content type = %q", ev.Payload.ResponseContentType) + } +} + +func TestAudit_CapturesIdentity(t *testing.T) { + ml := audit.NewMemoryLogger() + registry := endpoints.NewRegistry() + + mw := func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := inbound.WithIdentity(r.Context(), &inbound.Identity{ + Subject: "alice", AuthType: "apikey", KeyName: "devkey", + }) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } + h := mw(Audit(ml, registry, discardLogger(), AuditOptions{ + CapturePayloads: false, + })(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }))) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/x", nil)) + + snap := ml.Snapshot() + if len(snap) != 1 { + t.Fatalf("snapshot len = %d", len(snap)) + } + ev := snap[0] + if ev.UserSubject != "alice" || ev.AuthType != "apikey" || ev.APIKeyName != "devkey" { + t.Errorf("identity not captured: %+v", ev) + } +} + +func TestAudit_FailureMarkedOnNon2xx(t *testing.T) { + ml := audit.NewMemoryLogger() + registry := endpoints.NewRegistry() + + h := Audit(ml, registry, discardLogger(), AuditOptions{})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/y", nil)) + + ev := ml.Snapshot()[0] + if ev.Status != 500 || ev.Success { + t.Errorf("status=%d success=%v", ev.Status, ev.Success) + } +} + +func TestAudit_ResponseTruncated(t *testing.T) { + ml := audit.NewMemoryLogger() + h := Audit(ml, endpoints.NewRegistry(), discardLogger(), AuditOptions{ + CapturePayloads: true, MaxPayloadBytes: 16, + })(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Repeat("X", 64))) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + + ev := ml.Snapshot()[0] + if ev.Payload == nil || !ev.Payload.ResponseTruncated { + t.Errorf("expected truncated, payload=%+v", ev.Payload) + } + if len(ev.Payload.ResponseBody) != 16 { + t.Errorf("captured body len = %d, want 16", len(ev.Payload.ResponseBody)) + } + if ev.BytesOut != 64 { + t.Errorf("BytesOut = %d, want 64", ev.BytesOut) + } +} + +func TestAudit_RequestBodyCaptured(t *testing.T) { + ml := audit.NewMemoryLogger() + h := Audit(ml, endpoints.NewRegistry(), discardLogger(), AuditOptions{ + CapturePayloads: true, MaxPayloadBytes: 1024, + })(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Drain body so the tee pulls everything. + buf, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf) + })) + body := strings.NewReader(`hello world`) + r := httptest.NewRequest(http.MethodPost, "/", body) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + + ev := ml.Snapshot()[0] + if string(ev.Payload.RequestBody) != "hello world" { + t.Errorf("request body = %q", string(ev.Payload.RequestBody)) + } + if ev.BytesIn != 11 { + t.Errorf("BytesIn = %d, want 11", ev.BytesIn) + } +} + +// --- AccessLog --- + +func TestAccessLog_PassesThrough(t *testing.T) { + called := false + h := AccessLog(discardLogger())(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("tea")) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if !called { + t.Error("handler not called") + } + if w.Code != http.StatusTeapot { + t.Errorf("status = %d", w.Code) + } +} + +// TestAccessLog_SeesIdentitySetByPerRouteMiddleware locks down the +// coordination between RequestID, AccessLog, and Identity. AccessLog +// wraps the mux from the outside; Identity runs inside the per-route +// chain. Without the per-request identityHolder seeded by RequestID, +// AccessLog would always see nil because `r.WithContext(...)` only +// flows downward. +func TestAccessLog_SeesIdentitySetByPerRouteMiddleware(t *testing.T) { + chain := inbound.NewChain(false, inbound.NewBearer([]config.FileBearerToken{{Name: "tok", Token: "good"}})) + + var captured []string + logger := slog.New(slog.NewTextHandler(&captureWriter{lines: &captured}, &slog.HandlerOptions{Level: slog.LevelInfo})) + + endpointMW := func(next http.Handler) http.Handler { + return Identity(chain, discardLogger())(next) + } + mux := http.NewServeMux() + mux.Handle("GET /v1/x", endpointMW(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }))) + stack := RequestID(AccessLog(logger)(mux)) + + r := httptest.NewRequest(http.MethodGet, "/v1/x", nil) + r.Header.Set("Authorization", "Bearer good") + w := httptest.NewRecorder() + stack.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + if len(captured) == 0 { + t.Fatal("no access log line emitted") + } + line := captured[len(captured)-1] + if !strings.Contains(line, "auth_type=bearer") { + t.Errorf("access log missing auth_type=bearer: %s", line) + } + if !strings.Contains(line, "subject=tok") { + t.Errorf("access log missing subject=tok: %s", line) + } +} + +type captureWriter struct{ lines *[]string } + +func (c *captureWriter) Write(p []byte) (int, error) { + *c.lines = append(*c.lines, string(p)) + return len(p), nil +} + +// Sanity check that the middleware stack composes without panicking. +func TestComposition(t *testing.T) { + chain := inbound.NewChain(true) + stack := func(next http.Handler) http.Handler { + return RequestID(AccessLog(discardLogger())(Identity(chain, discardLogger())( + Audit(audit.NoopLogger{}, endpoints.NewRegistry(), discardLogger(), AuditOptions{})(next), + ))) + } + h := stack(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Identity must have populated. + if id := inbound.FromContext(r.Context()); id == nil { + t.Error("identity missing") + } + // Request id must be set. + if RequestIDFromContext(r.Context()) == "" { + t.Error("request id missing") + } + w.WriteHeader(http.StatusNoContent) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if w.Code != http.StatusNoContent { + t.Errorf("status %d", w.Code) + } + _ = context.Background() +} diff --git a/pkg/httpmw/requestid.go b/pkg/httpmw/requestid.go new file mode 100644 index 0000000..29a1f94 --- /dev/null +++ b/pkg/httpmw/requestid.go @@ -0,0 +1,87 @@ +// Package httpmw provides request-scoped HTTP middleware: request-id +// propagation, identity resolution from the inbound auth chain, structured +// access logging, and the audit middleware that writes Event/Payload rows +// for each request. +// +// Composition this package expects: +// +// RequestID -> AccessLog -> mux -> [per-route: Identity -> Audit] -> handler +// +// RequestID and AccessLog wrap the entire mux so health probes get +// request ids and access logs. Identity and Audit are per-route; the +// resolved Identity is stashed into a sharedIdentityRef set up by +// RequestID so AccessLog (which sees the request *before* Identity runs) +// can still read the resolved identity *after* the inner handler returns. +// Without that holder, AccessLog would always see nil because +// `r.WithContext(ctx)` inside Identity only flows downward. +package httpmw + +import ( + "context" + "net/http" + + "github.com/google/uuid" + + "github.com/plexara/api-test/pkg/auth/inbound" +) + +// HeaderRequestID is the canonical header name; mirrors what most reverse +// proxies emit. +const HeaderRequestID = "X-Request-Id" + +type ( + requestIDKey struct{} + identityHolderKey struct{} +) + +// identityHolder is a request-scoped, single-goroutine-mutated container +// for the resolved inbound.Identity. Lets middleware that wraps the mux +// from outside (AccessLog) read what middleware that runs inside the +// mux (Identity) wrote, since `r.WithContext(...)` only flows downward. +type identityHolder struct { + id *inbound.Identity +} + +// RequestID returns middleware that ensures every request has an +// X-Request-Id (preserving an inbound one or generating a new UUID), +// stashes it in the context, echoes it on the response, and seeds the +// per-request identityHolder so downstream middleware can record/read +// the resolved inbound identity. +func RequestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(HeaderRequestID) + if id == "" { + id = uuid.NewString() + } + w.Header().Set(HeaderRequestID, id) + ctx := context.WithValue(r.Context(), requestIDKey{}, id) + ctx = context.WithValue(ctx, identityHolderKey{}, &identityHolder{}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequestIDFromContext returns the request id attached by RequestID, or +// "" if absent. +func RequestIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(requestIDKey{}).(string) + return id +} + +// recordIdentity stores id into the per-request holder when present. +// Called by the Identity middleware after the auth chain returns. +func recordIdentity(ctx context.Context, id *inbound.Identity) { + h, _ := ctx.Value(identityHolderKey{}).(*identityHolder) + if h != nil { + h.id = id + } +} + +// resolvedIdentity returns the identity recorded into the holder, or nil +// if RequestID didn't seed a holder or Identity hasn't run yet. +func resolvedIdentity(ctx context.Context) *inbound.Identity { + h, _ := ctx.Value(identityHolderKey{}).(*identityHolder) + if h == nil { + return nil + } + return h.id +} diff --git a/pkg/httpsrv/cors.go b/pkg/httpsrv/cors.go new file mode 100644 index 0000000..bde3531 --- /dev/null +++ b/pkg/httpsrv/cors.go @@ -0,0 +1,21 @@ +package httpsrv + +import "net/http" + +// CORS adds permissive CORS headers suitable for an OSS test fixture. +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS") + h.Set("Access-Control-Allow-Headers", + "Authorization, Content-Type, X-API-Key, X-Request-Id") + h.Set("Access-Control-Expose-Headers", "X-Request-Id") + h.Set("Access-Control-Max-Age", "600") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/httpsrv/health.go b/pkg/httpsrv/health.go new file mode 100644 index 0000000..f7d6dff --- /dev/null +++ b/pkg/httpsrv/health.go @@ -0,0 +1,46 @@ +// Package httpsrv composes the HTTP mux that mounts api-test's endpoint +// groups, health probes, and (later milestones) the portal SPA, admin API, +// and well-known metadata. +package httpsrv + +import ( + "net/http" + "sync/atomic" +) + +// Readiness tracks the server's "ready to accept new traffic" flag. Flipped +// to false during shutdown so load balancers can drain. +type Readiness struct { + ready atomic.Bool +} + +// NewReadiness returns a Readiness initialised to true. +func NewReadiness() *Readiness { + r := &Readiness{} + r.ready.Store(true) + return r +} + +// SetReady toggles the flag. +func (r *Readiness) SetReady(v bool) { r.ready.Store(v) } + +// HealthzHandler returns 200 unconditionally; used for liveness. +func HealthzHandler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + } +} + +// ReadyzHandler returns 200 if ready, 503 otherwise. +func (r *Readiness) ReadyzHandler() http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + if r.ready.Load() { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ready")) + return + } + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("draining")) + } +} diff --git a/pkg/httpsrv/health_test.go b/pkg/httpsrv/health_test.go new file mode 100644 index 0000000..eef37e6 --- /dev/null +++ b/pkg/httpsrv/health_test.go @@ -0,0 +1,73 @@ +package httpsrv + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthz(t *testing.T) { + w := httptest.NewRecorder() + HealthzHandler()(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + if w.Code != http.StatusOK { + t.Errorf("status %d", w.Code) + } + if w.Body.String() != "ok" { + t.Errorf("body %q", w.Body.String()) + } +} + +func TestReadyz_Toggle(t *testing.T) { + r := NewReadiness() + + w := httptest.NewRecorder() + r.ReadyzHandler()(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if w.Code != http.StatusOK { + t.Errorf("ready status %d", w.Code) + } + + r.SetReady(false) + w = httptest.NewRecorder() + r.ReadyzHandler()(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("draining status %d", w.Code) + } +} + +func TestCORS_Preflight(t *testing.T) { + called := false + h := CORS(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called = true + })) + + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodOptions, "/", nil)) + if w.Code != http.StatusNoContent { + t.Errorf("preflight status %d", w.Code) + } + if called { + t.Error("preflight should short-circuit") + } + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("CORS Allow-Origin missing") + } +} + +func TestCORS_PassThrough(t *testing.T) { + called := false + h := CORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusTeapot) + })) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil)) + if !called { + t.Error("non-preflight should call next") + } + if w.Code != http.StatusTeapot { + t.Errorf("status %d", w.Code) + } + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("Allow-Origin missing on non-preflight") + } +} diff --git a/pkg/httpsrv/mux.go b/pkg/httpsrv/mux.go new file mode 100644 index 0000000..fbfa1a4 --- /dev/null +++ b/pkg/httpsrv/mux.go @@ -0,0 +1,64 @@ +package httpsrv + +import ( + "encoding/json" + "net/http" + + "github.com/plexara/api-test/pkg/endpoints" +) + +// BuildMux assembles the M1 HTTP mux: /healthz, /readyz, the endpoint +// groups under /v1, and a friendly JSON 404 root. Later milestones will +// extend this with /.well-known/*, the portal SPA at /portal/, the +// portal/admin APIs at /api/v1/portal/, and the OpenAPI doc. +func BuildMux(registry *endpoints.Registry, readiness *Readiness, mw endpoints.Middleware) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /healthz", HealthzHandler()) + mux.HandleFunc("GET /readyz", readiness.ReadyzHandler()) + + if mw == nil { + mw = endpoints.PassthroughMiddleware + } + registry.Mount(mux, mw) + + mux.HandleFunc("GET /", rootHandler(registry)) + + return CORS(mux) +} + +// rootHandler returns a small JSON banner at "/" so a curl to the bare host +// gets a useful response (a list of mounted endpoint groups + version +// pointer to /healthz). M3 will replace this with a 302 to /portal/ when +// the SPA is enabled. +func rootHandler(registry *endpoints.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Only handle the literal root; everything else here means "no + // matching route" and should be a JSON 404. + if r.URL.Path != "/" { + writeJSONError(w, http.StatusNotFound, "no such endpoint") + return + } + groups := make([]string, 0, len(registry.Groups())) + for _, g := range registry.Groups() { + groups = append(groups, g.Name()) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": "api-test", + "endpoint_groups": groups, + "endpoints": len(registry.All()), + "links": map[string]string{ + "healthz": "/healthz", + "readyz": "/readyz", + }, + }) + } +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/pkg/httpsrv/mux_test.go b/pkg/httpsrv/mux_test.go new file mode 100644 index 0000000..82111c9 --- /dev/null +++ b/pkg/httpsrv/mux_test.go @@ -0,0 +1,75 @@ +package httpsrv + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/plexara/api-test/pkg/endpoints" +) + +type stubGroup struct{} + +func (stubGroup) Name() string { return "stub" } +func (stubGroup) Routes() []endpoints.EndpointMeta { + return []endpoints.EndpointMeta{{Name: "ping", Method: "GET", Path: "/v1/ping"}} +} +func (stubGroup) Mount(mux *http.ServeMux, mw endpoints.Middleware) { + mux.Handle("GET /v1/ping", mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("pong")) + }))) +} + +func TestBuildMux_RootBanner(t *testing.T) { + r := endpoints.NewRegistry() + r.Add(stubGroup{}) + mux := BuildMux(r, NewReadiness(), nil) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status %d", w.Code) + } + var body map[string]any + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["name"] != "api-test" { + t.Errorf("name = %v", body["name"]) + } +} + +func TestBuildMux_Healthz(t *testing.T) { + mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil) + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Errorf("status %d", w.Code) + } +} + +func TestBuildMux_GroupMounted(t *testing.T) { + r := endpoints.NewRegistry() + r.Add(stubGroup{}) + mux := BuildMux(r, NewReadiness(), nil) + req := httptest.NewRequest(http.MethodGet, "/v1/ping", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusOK || w.Body.String() != "pong" { + t.Errorf("group not reachable: status=%d body=%q", w.Code, w.Body.String()) + } +} + +func TestBuildMux_404(t *testing.T) { + mux := BuildMux(endpoints.NewRegistry(), NewReadiness(), nil) + req := httptest.NewRequest(http.MethodGet, "/no-such-thing", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("status %d want 404", w.Code) + } +} diff --git a/tests/audit_test.go b/tests/audit_test.go new file mode 100644 index 0000000..8164894 --- /dev/null +++ b/tests/audit_test.go @@ -0,0 +1,247 @@ +//go:build integration + +package tests + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/plexara/api-test/pkg/audit" + auditpg "github.com/plexara/api-test/pkg/audit/postgres" +) + +// TestIntegration_AuditCapture verifies that a successful API call lands +// as a row in audit_events with the right identity, status, route, and +// duration, and that the audit_payloads sibling row carries redacted +// headers + the response body. +func TestIntegration_AuditCapture(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + pgURL := startPostgres(ctx, t) + url, _ := boot(t, pgURL) + + client := authenticatedClient() + resp, err := client.Get(url + "/v1/whoami") + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("whoami status %d", resp.StatusCode) + } + + // Audit pipeline is async-buffered (AsyncLogger). Poll until visible. + pool, err := pgxpool.New(ctx, pgURL) + if err != nil { + t.Fatalf("pgx pool: %v", err) + } + t.Cleanup(pool.Close) + store := auditpg.New(pool) + + events := pollEvents(t, store, ctx, 1, 3*time.Second) + if len(events) != 1 { + t.Fatalf("events = %d", len(events)) + } + ev := events[0] + if ev.Method != "GET" || ev.Path != "/v1/whoami" { + t.Errorf("method/path = %s %s", ev.Method, ev.Path) + } + if ev.Status != 200 || !ev.Success { + t.Errorf("status=%d success=%v", ev.Status, ev.Success) + } + if ev.AuthType != "apikey" || ev.UserSubject != "intkey" { + t.Errorf("identity not captured: %+v", ev) + } + if ev.RouteName == "" { + t.Errorf("route_name empty (registry didn't tag route)") + } + if ev.DurationMS < 0 { + t.Errorf("duration %d", ev.DurationMS) + } + + // Payload row: headers redacted, response body present. + pl, err := store.GetPayload(ctx, ev.ID) + if err != nil { + t.Fatalf("GetPayload: %v", err) + } + if pl == nil { + t.Fatal("payload nil") + } + if v := pl.RequestHeaders["X-Api-Key"]; len(v) != 1 || v[0] != "[redacted]" { + t.Errorf("X-API-Key not redacted in payload: %v", v) + } + var body map[string]any + if err := json.Unmarshal(pl.ResponseBody, &body); err != nil { + t.Fatalf("response body not JSON: %v\n%q", err, pl.ResponseBody) + } + if body["auth_type"] != "apikey" { + t.Errorf("payload body auth_type = %v", body["auth_type"]) + } + if pl.ResponseContentType != "application/json" { + t.Errorf("response content type = %q", pl.ResponseContentType) + } +} + +// TestIntegration_AuditFailureMarked verifies that a 5xx response writes +// success=false in the audit row. +func TestIntegration_AuditFailureMarked(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + pgURL := startPostgres(ctx, t) + url, _ := boot(t, pgURL) + + client := authenticatedClient() + resp, err := client.Get(url + "/v1/status/503") + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + pool, err := pgxpool.New(ctx, pgURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + store := auditpg.New(pool) + + events := pollEvents(t, store, ctx, 1, 3*time.Second) + var failure *audit.Event + for i := range events { + if events[i].Path == "/v1/status/503" { + failure = &events[i] + break + } + } + if failure == nil { + t.Fatalf("no audit row for /v1/status/503 in %d events", len(events)) + } + if failure.Status != 503 || failure.Success { + t.Errorf("status=%d success=%v", failure.Status, failure.Success) + } +} + +// TestIntegration_HealthzNotAudited confirms /healthz doesn't produce an +// audit row (it sits outside the endpoint middleware stack). +func TestIntegration_HealthzNotAudited(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + pgURL := startPostgres(ctx, t) + url, _ := boot(t, pgURL) + + for i := 0; i < 3; i++ { + resp, err := http.Get(url + "/healthz") + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + } + + // Give the (nonexistent) audit pipeline a moment. + time.Sleep(200 * time.Millisecond) + + pool, err := pgxpool.New(ctx, pgURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + store := auditpg.New(pool) + cnt, err := store.Count(ctx, audit.QueryFilter{Path: "/healthz"}) + if err != nil { + t.Fatal(err) + } + if cnt != 0 { + t.Errorf("/healthz audited %d times, want 0", cnt) + } +} + +// TestIntegration_QueryFilters checks that the Postgres store honors the +// QueryFilter predicates used by the (M3) portal API. +func TestIntegration_QueryFilters(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + pgURL := startPostgres(ctx, t) + url, _ := boot(t, pgURL) + + client := authenticatedClient() + for _, path := range []string{ + "/v1/whoami", + "/v1/status/200", + "/v1/status/500", + "/v1/whoami", + } { + resp, err := client.Get(url + path) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + + pool, err := pgxpool.New(ctx, pgURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + store := auditpg.New(pool) + + // Wait until at least 4 audited events. + pollEvents(t, store, ctx, 4, 3*time.Second) + + // Filter by status. + cnt, err := store.Count(ctx, audit.QueryFilter{Status: 500}) + if err != nil { + t.Fatal(err) + } + if cnt != 1 { + t.Errorf("status=500 count = %d, want 1", cnt) + } + + // Filter by user. + cnt, err = store.Count(ctx, audit.QueryFilter{UserID: "intkey"}) + if err != nil { + t.Fatal(err) + } + if cnt != 4 { + t.Errorf("user=intkey count = %d, want 4", cnt) + } + + // Search across path. + cnt, err = store.Count(ctx, audit.QueryFilter{Search: "whoami"}) + if err != nil { + t.Fatal(err) + } + if cnt != 2 { + t.Errorf("search=whoami count = %d, want 2", cnt) + } +} + +func pollEvents(t *testing.T, store *auditpg.Store, ctx context.Context, atLeast int, timeout time.Duration) []audit.Event { + t.Helper() + deadline := time.Now().Add(timeout) + var events []audit.Event + var lastErr error + for time.Now().Before(deadline) { + events, lastErr = store.Query(ctx, audit.QueryFilter{Limit: 100}) + if lastErr == nil && len(events) >= atLeast { + return events + } + time.Sleep(50 * time.Millisecond) + } + if lastErr != nil { + t.Fatalf("audit query: %v", lastErr) + } + return events +} + +// Sanity check that strings package is referenced (audit_payloads test +// uses Contains in the response-body assertion). +var _ = strings.Contains diff --git a/tests/auth_matrix_test.go b/tests/auth_matrix_test.go new file mode 100644 index 0000000..abe6ba5 --- /dev/null +++ b/tests/auth_matrix_test.go @@ -0,0 +1,124 @@ +//go:build integration + +package tests + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" +) + +// TestIntegration_AuthMatrix walks every supported inbound credential +// type the M2 chain knows about against /v1/whoami: +// - no credential → 401 + WWW-Authenticate +// - wrong api key → 401 +// - X-API-Key (header) → 200, auth_type=apikey +// - api_key (query) → 200, auth_type=apikey +// - Authorization: Bearer (static token) → 200, auth_type=bearer +// - Authorization: Bearer (wrong token) → 401 +func TestIntegration_AuthMatrix(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + pgURL := startPostgres(ctx, t) + url, _ := boot(t, pgURL) + + cases := []struct { + name string + setup func(*http.Request) + wantStatus int + wantAuth string + wantSubj string + }{ + { + name: "no credential", + setup: func(*http.Request) {}, + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong api key", + setup: func(r *http.Request) { + r.Header.Set("X-API-Key", "wrong") + }, + wantStatus: http.StatusUnauthorized, + }, + { + name: "X-API-Key header", + setup: func(r *http.Request) { + r.Header.Set("X-API-Key", TestAPIKey) + }, + wantStatus: http.StatusOK, + wantAuth: "apikey", + wantSubj: "intkey", + }, + { + name: "api_key query", + setup: func(r *http.Request) { + q := r.URL.Query() + q.Set("api_key", TestAPIKey) + r.URL.RawQuery = q.Encode() + }, + wantStatus: http.StatusOK, + wantAuth: "apikey", + wantSubj: "intkey", + }, + { + name: "Bearer static token", + setup: func(r *http.Request) { + r.Header.Set("Authorization", "Bearer "+TestBearerToken) + }, + wantStatus: http.StatusOK, + wantAuth: "bearer", + wantSubj: "intbearer", + }, + { + name: "Bearer wrong", + setup: func(r *http.Request) { + r.Header.Set("Authorization", "Bearer wrongness") + }, + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, url+"/v1/whoami", nil) + if err != nil { + t.Fatal(err) + } + tc.setup(req) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != tc.wantStatus { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("status %d want %d body=%s", resp.StatusCode, tc.wantStatus, body) + } + if tc.wantStatus == http.StatusUnauthorized { + if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Bearer") { + t.Errorf("WWW-Authenticate missing: %q", resp.Header.Get("WWW-Authenticate")) + } + return + } + + var body map[string]any + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body["auth_type"] != tc.wantAuth { + t.Errorf("auth_type=%v want %s", body["auth_type"], tc.wantAuth) + } + if body["subject"] != tc.wantSubj { + t.Errorf("subject=%v want %s", body["subject"], tc.wantSubj) + } + }) + } +} diff --git a/tests/helpers_integration_test.go b/tests/helpers_integration_test.go new file mode 100644 index 0000000..7a8e161 --- /dev/null +++ b/tests/helpers_integration_test.go @@ -0,0 +1,145 @@ +//go:build integration + +// Package tests / integration covers the full HTTP + Postgres stack +// end-to-end. Requires Docker on the host; run with +// `go test -tags integration ./tests/...`. +package tests + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + "github.com/plexara/api-test/internal/server" + "github.com/plexara/api-test/pkg/config" +) + +func slogDiscard(t *testing.T) *slog.Logger { + t.Helper() + return slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError})) +} + +// startPostgres boots a postgres:16-alpine container via testcontainers +// and returns its connection URL with sslmode=disable. +func startPostgres(ctx context.Context, t *testing.T) string { + t.Helper() + pgC, err := tcpostgres.Run(ctx, + "postgres:16-alpine", + tcpostgres.WithDatabase("apitest"), + tcpostgres.WithUsername("api"), + tcpostgres.WithPassword("api"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + t.Fatalf("start postgres: %v", err) + } + t.Cleanup(func() { _ = pgC.Terminate(context.Background()) }) + + url, err := pgC.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("conn string: %v", err) + } + return url +} + +// boot constructs the full Application against the supplied Postgres URL +// with the api-test fixture endpoints enabled, audit on, and a known set +// of API keys / bearer tokens. +// +// Returns the live httptest.Server URL and the Application (so tests can +// reach into AuditLog for assertions). Cleanup is registered with t. +func boot(t *testing.T, pgURL string) (string, *server.Application) { + t.Helper() + cfg := &config.Config{ + Auth: config.AuthConfig{AllowAnonymous: false}, + APIKeys: config.APIKeysConfig{ + HeaderName: "X-API-Key", + QueryParamName: "api_key", + File: []config.FileAPIKey{ + {Name: "intkey", Key: TestAPIKey, Description: "integration"}, + }, + }, + Bearer: config.BearerConfig{ + Tokens: []config.FileBearerToken{ + {Name: "intbearer", Token: TestBearerToken, Description: "integration"}, + }, + }, + Database: config.DatabaseConfig{URL: pgURL}, + Audit: config.AuditConfig{ + Enabled: true, + RetentionDays: 1, + MaxPayloadBytes: 64 * 1024, + }, + Endpoints: config.EndpointsConfig{ + Identity: config.EndpointGroupConfig{Enabled: true}, + Data: config.EndpointGroupConfig{Enabled: true}, + Failure: config.EndpointGroupConfig{Enabled: true}, + Echo: config.EndpointGroupConfig{Enabled: true}, + }, + } + cfg.Server.Address = ":0" + cfg.Server.Shutdown.GracePeriod = 5 * time.Second + cfg.Server.ReadHeaderTimeout = 5 * time.Second + // Apply defaults that Load() would normally apply. + if len(cfg.Audit.RedactKeys) == 0 { + cfg.Audit.RedactKeys = []string{ + "password", "token", "secret", "authorization", "api_key", + "api-key", "bearer", "cookie", + } + } + + logger := slogDiscard(t) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + app, err := server.Build(ctx, cfg, logger) + if err != nil { + t.Fatalf("server.Build: %v", err) + } + t.Cleanup(app.Close) + + ts := httptest.NewServer(app.Handler()) + t.Cleanup(ts.Close) + + return ts.URL, app +} + +// TestAPIKey and TestBearerToken are the credentials boot() seeds. Used +// by every integration test so they don't have to redeclare them. +const ( + TestAPIKey = "integration-api-key-secret" + TestBearerToken = "integration-bearer-token-secret" +) + +// authenticatedClient returns an HTTP client whose RoundTripper injects +// the standard X-API-Key header on every request. +func authenticatedClient() *http.Client { + return &http.Client{Transport: withHeader(http.DefaultTransport, "X-API-Key", TestAPIKey)} +} + +// withHeader wraps rt to add a static header on every request. +func withHeader(rt http.RoundTripper, key, value string) http.RoundTripper { + return &headerInjector{rt: rt, key: key, value: value} +} + +type headerInjector struct { + rt http.RoundTripper + key, value string +} + +func (h *headerInjector) RoundTrip(r *http.Request) (*http.Response, error) { + clone := r.Clone(r.Context()) + clone.Header.Set(h.key, h.value) + return h.rt.RoundTrip(clone) +} From c75b90a1bb7a99fd0554419d49a09bb38333f68a Mon Sep 17 00:00:00 2001 From: cjimti Date: Sat, 9 May 2026 16:28:50 -0700 Subject: [PATCH 2/5] add documentation site mirroring mcp-test style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site lives at https://api-test.plexara.io (CNAME shipped) and is deployed by .github/workflows/docs.yml on push to main when files under docs/, mkdocs.yml, or that workflow change. The chrome (footer, hero, capabilities grid, fonts, palette, OG card layout) is copied from mcp-test verbatim where it doesn't depend on project specifics. Content: - index.md (uses overrides/home.html template) — hero + capabilities grid + why-this-exists rail. The portal-screenshots carousel from mcp-test/home.html is intentionally omitted with a comment because the api-test portal lands in M3. - getting-started/{overview, installation, quickstart, register-with-plexara}.md - configuration/{reference, environment, auth, database}.md - endpoints/{overview, identity, data, failure, echo}.md - operations/{audit, portal, deployment, gateway-testing}.md - reference/{http-api, architecture, releases}.md Brand assets: - logo.svg/png copied verbatim (Plexara mark) - og-card.svg/png adapted: same midnight + copper palette and 1200x630 layout as mcp-test, but the right panel renders an HTTP request envelope (request line, headers including [redacted] X-API-Key, status, JSON response) instead of a JSON-RPC envelope Five pre-commit-review findings addressed across three rounds: - docs/configuration/auth.md referenced non-existent oidc.redirect_path; corrected to portal.oidc_redirect_path. - docs/configuration/reference.md documented auth.require_for_api / require_for_portal defaults as true, but the Go struct fields zero to false and aren't currently consumed by the middleware. Updated to "**Reserved**" with the actual default and a note that the shipped live.yaml opts in to true. - docs/getting-started/quickstart.md described `make dev` as bringing up Postgres + Keycloak + the binary against api-test.live.yaml. Today `make dev` aliases to `make dev-anon` (anonymous, no DB, no Keycloak). Rewrote to describe today's behavior, added an "Auth-enabled iteration" section that exercises the auth chain without Keycloak, and called out the full stack as M3-future. The same stale claim was repeated in docs/llms.txt, docs/index.md, docs/getting-started/{overview,installation}.md — fixed in all five places so the cross-page surface is consistent. - docs/reference/architecture.md listed CORS as the outermost middleware, but actual order is RequestID(AccessLog(CORS(mux))). Reordered the numbered request-flow list and clarified that Identity / Audit are per-route and that AccessLog reads identity via the holder seeded by RequestID, not via inbound.FromContext. `mkdocs build --strict` clean. `make verify` clean. The docs deploy will fire on the first push to main that touches one of the workflow's path filters. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/docs.yml | 59 + docs/CNAME | 1 + docs/configuration/auth.md | 161 ++ docs/configuration/database.md | 109 + docs/configuration/environment.md | 73 + docs/configuration/reference.md | 161 ++ docs/endpoints/data.md | 137 + docs/endpoints/echo.md | 108 + docs/endpoints/failure.md | 166 ++ docs/endpoints/identity.md | 125 + docs/endpoints/overview.md | 78 + docs/fonts/dm-sans-latin-ext.woff2 | Bin 0 -> 31312 bytes docs/fonts/dm-sans-latin.woff2 | Bin 0 -> 62556 bytes docs/fonts/outfit-latin-ext.woff2 | Bin 0 -> 14760 bytes docs/fonts/outfit-latin.woff2 | Bin 0 -> 32228 bytes docs/getting-started/installation.md | 85 + docs/getting-started/overview.md | 87 + docs/getting-started/quickstart.md | 138 + docs/getting-started/register-with-plexara.md | 217 ++ docs/images/logo.png | Bin 0 -> 25327 bytes docs/images/logo.svg | 282 ++ docs/images/og-card.png | Bin 0 -> 112491 bytes docs/images/og-card.svg | 160 ++ docs/index.md | 110 + docs/javascripts/shots.js | 233 ++ docs/llms.txt | 48 + docs/operations/audit.md | 163 ++ docs/operations/deployment.md | 135 + docs/operations/gateway-testing.md | 199 ++ docs/operations/portal.md | 80 + docs/overrides/home.html | 196 ++ docs/overrides/main.html | 177 ++ docs/reference/architecture.md | 200 ++ docs/reference/http-api.md | 132 + docs/reference/releases.md | 76 + docs/robots.txt | 4 + docs/stylesheets/extra.css | 2392 +++++++++++++++++ mkdocs.yml | 156 ++ 38 files changed, 6448 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/CNAME create mode 100644 docs/configuration/auth.md create mode 100644 docs/configuration/database.md create mode 100644 docs/configuration/environment.md create mode 100644 docs/configuration/reference.md create mode 100644 docs/endpoints/data.md create mode 100644 docs/endpoints/echo.md create mode 100644 docs/endpoints/failure.md create mode 100644 docs/endpoints/identity.md create mode 100644 docs/endpoints/overview.md create mode 100644 docs/fonts/dm-sans-latin-ext.woff2 create mode 100644 docs/fonts/dm-sans-latin.woff2 create mode 100644 docs/fonts/outfit-latin-ext.woff2 create mode 100644 docs/fonts/outfit-latin.woff2 create mode 100644 docs/getting-started/installation.md create mode 100644 docs/getting-started/overview.md create mode 100644 docs/getting-started/quickstart.md create mode 100644 docs/getting-started/register-with-plexara.md create mode 100644 docs/images/logo.png create mode 100644 docs/images/logo.svg create mode 100644 docs/images/og-card.png create mode 100644 docs/images/og-card.svg create mode 100644 docs/index.md create mode 100644 docs/javascripts/shots.js create mode 100644 docs/llms.txt create mode 100644 docs/operations/audit.md create mode 100644 docs/operations/deployment.md create mode 100644 docs/operations/gateway-testing.md create mode 100644 docs/operations/portal.md create mode 100644 docs/overrides/home.html create mode 100644 docs/overrides/main.html create mode 100644 docs/reference/architecture.md create mode 100644 docs/reference/http-api.md create mode 100644 docs/reference/releases.md create mode 100644 docs/robots.txt create mode 100644 docs/stylesheets/extra.css create mode 100644 mkdocs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..940c85c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,59 @@ +name: Deploy Documentation + +on: + push: + branches: + - main + paths: + - "docs/**" + - "mkdocs.yml" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + pip install \ + mkdocs-material \ + 'pymdown-extensions>=10.0' + + - name: Build documentation + run: mkdocs build --strict + + - name: Upload artifact + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..e7656f5 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +api-test.plexara.io diff --git a/docs/configuration/auth.md b/docs/configuration/auth.md new file mode 100644 index 0000000..f73c38d --- /dev/null +++ b/docs/configuration/auth.md @@ -0,0 +1,161 @@ +--- +title: Authentication +description: Inbound auth modes (file API keys with header or query placement, Postgres-backed bcrypt keys, static bearer tokens, OIDC JWT validation) matching what the Plexara API gateway sends. +--- + +# Authentication + +api-test validates *inbound* credentials — the credentials the Plexara +gateway forwards from its caller. The chain is: + +1. **API key** — header (default `X-API-Key`) or query param (default + `api_key`). Validated against the `api_keys.file` static list and, + 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 + the configured IdP's JWKS. +4. **Anonymous fallback** — when `auth.allow_anonymous: true` and no + credential matched, requests proceed with an anonymous identity. + +The first authenticator that finds its credential type in the request +decides the outcome. A bad credential **does not** fall through; the +chain returns 401 immediately. This prevents accidental cross-mode +matches (a typo'd JWT shouldn't accidentally pass the static-bearer +list). + +## File API keys + +Simplest, no DB required. + +```yaml +auth: + allow_anonymous: false + +api_keys: + header_name: "X-API-Key" + query_param_name: "api_key" + file: + - { name: "devkey", key: "${APITEST_DEV_KEY}", description: "default dev key" } + - { name: "ci", key: "${APITEST_CI_KEY}", description: "CI smoke tests" } +``` + +Lookup is constant-time (`subtle.ConstantTimeCompare` per row) so a +timing-side-channel attacker can't fingerprint which entry matched. + +## Postgres-backed API keys + +Bcrypt-hashed; CRUD'd via the portal API. Slow per row (bcrypt is +intentionally slow), so list scans cap at ~thousands of keys before +they stop being practical. For a test fixture, that's fine. + +```yaml +api_keys: + db: + enabled: true + +database: + url: "${APITEST_DB_URL}" +``` + +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 +the admin API directly: + +```bash +curl -s -X POST http://localhost:8080/api/v1/admin/api-keys \ + -H "X-API-Key: $APITEST_DEV_KEY" \ + -H "Content-Type: application/json" \ + -d '{"name":"plexara-prod","description":"plexara production"}' +# → { "key": { "id": "...", "name": "plexara-prod", ... }, +# "plaintext": "at_..." } +``` + +The `plaintext` value is shown once and never persisted. Capture it, +hand it to the gateway, and don't ask for it again. + +## Static bearer tokens + +Mirror image of the API-key file store, against `Authorization: Bearer`. + +```yaml +bearer: + tokens: + - { name: "devbearer", token: "${APITEST_DEV_BEARER}", description: "default dev bearer" } +``` + +Used when a Plexara connection is configured with `auth_mode: bearer` +and `credential: `. + +## OIDC JWT (M3+) + +When the Plexara gateway uses `oauth2_client_credentials` or +`oauth2_authorization_code`, it exchanges with the IdP and forwards the +resulting access token to api-test. api-test validates the JWT against +the configured IdP's JWKS. + +```yaml +oidc: + enabled: true + issuer: "http://keycloak.local:8081/realms/api-test" + audience: "api-test" + allowed_clients: ["plexara-cc", "plexara-ac"] + clock_skew_seconds: 30 + jwks_cache_ttl: 1h +``` + +JWKS is cached in-process for `jwks_cache_ttl`. Validation checks: + +- Signature against a key from the cached JWKS. +- `iss` matches `oidc.issuer`. +- `aud` contains `oidc.audience`. +- `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 +two confidential clients (`plexara-cc` for client-credentials, +`plexara-ac` for auth-code) and a portal user (`dev` / `dev`). + +## 401 responses + +Every 401 carries an RFC 6750 `WWW-Authenticate` header so an HTTP +client can discover the auth scheme: + +```http +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer realm="api-test" +Content-Type: application/json + +{"error":"missing credential"} +``` + +When the credential was supplied but invalid: + +```http +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer realm="api-test", error="invalid_token" +Content-Type: application/json + +{"error":"invalid credential"} +``` + +## Anonymous mode + +```yaml +auth: + allow_anonymous: true +``` + +The chain still runs every authenticator; only when none match does it +fall back to anonymous. So a bad credential still returns 401 — only +*absent* credentials get the anonymous identity. This makes the fixture +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+) + +The portal uses a standard OIDC PKCE flow: hit `/portal/`, redirect to +the IdP, callback at `portal.oidc_redirect_path`, set a session cookie. +The portal API checks the cookie; everything else still uses the +inbound auth chain. diff --git a/docs/configuration/database.md b/docs/configuration/database.md new file mode 100644 index 0000000..768ae11 --- /dev/null +++ b/docs/configuration/database.md @@ -0,0 +1,109 @@ +--- +title: Database & migrations +description: PostgreSQL connection settings; migrations run on boot via golang-migrate. +--- + +# Database & migrations + +api-test uses PostgreSQL 14+ for the audit log and (when enabled) the +bcrypt-backed API key store. Migrations live in +[`pkg/database/migrate/migrations`](https://github.com/plexara/api-test/tree/main/pkg/database/migrate/migrations) +and are embedded into the binary via `go:embed`. They run on every +startup via [golang-migrate](https://github.com/golang-migrate/migrate); +already-applied migrations are skipped. + +The database is *optional* when both `audit.enabled` and +`api_keys.db.enabled` are false; api-test runs without a DB in +anonymous + file-only-keys mode (the M1 happy path). + +## Configuration + +```yaml +database: + url: "${APITEST_DB_URL:-postgres://api:api@localhost:5432/apitest?sslmode=disable}" + max_open_conns: 25 + max_idle_conns: 5 + conn_max_lifetime: 1h +``` + +The DSN passes through to `pgxpool.ParseConfig`, so any +[pgx-supported form](https://pkg.go.dev/github.com/jackc/pgx/v5#ParseConfig) +works: `postgres://`, `postgresql://`, or libpq key=value strings. + +## Schema + +Three tables: + +- **`api_keys`** — bcrypt-hashed API keys (when `api_keys.db.enabled`). + Columns: `id`, `name`, `hash`, `description`, `created_by`, + `created_at`, `expires_at`, `last_used_at`. Unique on `name`. +- **`audit_events`** — indexable summary of one inbound request. + Columns: `id`, `ts`, `duration_ms`, `request_id`, `session_id`, + `user_subject`, `user_email`, `auth_type`, `api_key_name`, `method`, + `path`, `route_name`, `endpoint_group`, `status`, `bytes_in`, + `bytes_out`, `success`, `error_message`, `error_category`, + `remote_addr`, `user_agent`. Indexed on `(ts DESC)`, + `(route_name, ts DESC)`, `(path, ts DESC)`, + `(user_subject, ts DESC)`, `(session_id, ts DESC)`, + `(status, ts DESC)`. +- **`audit_payloads`** — full request/response envelope joined 1:1 with + `audit_events.id`. Columns: `event_id`, `request_headers`, + `request_query`, `request_content_type`, `request_body`, + `request_size_bytes`, `request_truncated`, `request_remote_addr`, + `response_headers`, `response_content_type`, `response_body`, + `response_size_bytes`, `response_truncated`, `replayed_from`, + `captured_at`. JSONB GIN indexes on `request_headers` and + `response_headers` for portal filtering. + +The two-table layout keeps the summary row free of multi-KB JSONB so +time/route/identity queries stay fast; the payload join only runs when +an operator drills into a single event in the portal. + +`ON DELETE CASCADE` from `audit_payloads.event_id` to `audit_events.id` +keeps retention cleanup atomic. + +## Retention + +```yaml +audit: + retention_days: 7 +``` + +Default is 7 days (lower than mcp-test's 30 — api-test responses can be +much larger; export endpoints emit 100 MiB bodies that inflate the +payload table fast). A future migration adds a periodic cleanup job; +for now, prune manually: + +```sql +DELETE FROM audit_events WHERE ts < now() - interval '7 days'; +-- audit_payloads cascades. +``` + +## Body capture caps + +Bodies that exceed `audit.max_payload_bytes` (default 1 MiB) are +truncated and the matching `request_truncated` / `response_truncated` +flag is set. The captured prefix is what's stored. Operators in +privacy-sensitive deployments can set: + +```yaml +audit: + capture_payloads: false # only the audit_events summary row + # or + capture_payloads: true + capture_headers: false # bodies but no headers +``` + +## Operations + +- **Pre-flight smoke** before pointing at production: + ```bash + pg_isready -h -U api -d apitest + ``` +- **Migration history** lives in `schema_migrations` (golang-migrate + default). +- **Down migrations** ship for every up; rollback works via `migrate -path … -database … down 1`. The binary itself only runs `up`. +- **Connection pool tuning**: defaults (25 open, 5 idle, 1h lifetime) + are conservative. For high-throughput deployments, raise + `max_open_conns` and lower `conn_max_lifetime` to rotate connections + faster. diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md new file mode 100644 index 0000000..780a2cd --- /dev/null +++ b/docs/configuration/environment.md @@ -0,0 +1,73 @@ +--- +title: Environment variables +description: APITEST_* environment variables and how they map onto the YAML config. +--- + +# Environment variables + +api-test resolves `${VAR}` and `${VAR:-default}` placeholders in its +YAML config at load time. Plain `$VAR` (no braces) is left untouched +so Postgres DSN-style strings don't get rewritten. + +This page documents the conventional `APITEST_*` variables the shipped +configs expect. + +## Convention + +```yaml +api_keys: + file: + - { name: "devkey", key: "${APITEST_DEV_KEY:-devkey-please-change}" } +``` + +`${APITEST_DEV_KEY:-devkey-please-change}` resolves to the env var +when set, else to the literal default. Use the default form for +fixtures that should "just work" out of the box; use the bare +`${APITEST_DEV_KEY}` form for required values where missing should +fail loudly. + +## Generated by `make dev-secrets` + +`make dev-secrets` writes `.env.dev` (gitignored) with random secrets on +first run. Subsequent runs reuse the file so sessions persist. + +| Variable | Used in | Description | +| --- | --- | --- | +| `APITEST_COOKIE_SECRET` | `portal.cookie_secret` | HMAC secret for the portal session cookie. | +| `APITEST_DEV_KEY` | `api_keys.file[0].key` | Default static API key for dev-mode auth. | +| `APITEST_DEV_BEARER` | `bearer.tokens[0].token` | Default static bearer token. | + +`source ./.env.dev` to load them into the current shell. + +## OIDC (M3+) + +| Variable | Used in | Description | +| --- | --- | --- | +| `APITEST_OIDC_ISSUER` | `oidc.issuer` | OIDC issuer URL. | +| `APITEST_OIDC_AUDIENCE` | `oidc.audience` | Expected `aud` claim. | +| `APITEST_INSECURE` | n/a (gate) | When set to `1`, allows `oidc.skip_signature_verification: true` in config. Never set in production. | + +## Database + +| Variable | Used in | Description | +| --- | --- | --- | +| `APITEST_DB_URL` | `database.url` | PostgreSQL DSN. The shipped `api-test.example.yaml` interpolates this. | + +## Plexara self-registration (M5+) + +| Variable | Used in | Description | +| --- | --- | --- | +| `PLEXARA_ADMIN_URL` | `plexara.register.admin_url` | Plexara admin API URL. | +| `PLEXARA_ADMIN_AUTH` | `plexara.register.auth_header` | Authorization header value (e.g. `Bearer `). | + +## Logging + +| Variable | Description | +| --- | --- | +| `LOG_LEVEL` | One of `debug`, `info` (default), `warn`, `error`. Lowercase or uppercase. | + +## Healthcheck + +| Variable | Description | +| --- | --- | +| `APITEST_HEALTHCHECK_URL` | URL `--healthcheck` probes. Defaults to `http://127.0.0.1:8080/healthz`. Useful when the binary listens on a non-default port inside a container. | diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md new file mode 100644 index 0000000..0b7ec6c --- /dev/null +++ b/docs/configuration/reference.md @@ -0,0 +1,161 @@ +--- +title: YAML reference +description: Every api-test config key, what it does, the default value, and the environment variable that overrides it. +--- + +# YAML reference + +api-test loads configuration from a single YAML file passed via +`--config`. Every key supports `${VAR}` and `${VAR:-default}` +interpolation; plain `$VAR` (no braces) is left untouched so Postgres +DSN-style strings round-trip unmodified. + +The full reference document, with every knob and a comment per knob, +lives at [`configs/api-test.example.yaml`](https://github.com/plexara/api-test/blob/main/configs/api-test.example.yaml). +This page is the human-friendly tour. + +## `server` + +| Key | Default | Description | +| --- | --- | --- | +| `name` | `"api-test"` | Server display name; surfaces in the OpenAPI doc and the portal. | +| `address` | `":8080"` | Listener address. Standard Go `host:port`. | +| `base_url` | `"http://localhost:"` | External base URL; used in generated OpenAPI servers list and any redirects. | +| `description` | (built-in) | Operator-facing prose served at `/` and in the OpenAPI `info.description`. | +| `read_header_timeout` | `10s` | `http.Server.ReadHeaderTimeout`. Mitigates slowloris. | +| `shutdown.grace_period` | `25s` | Time given to in-flight requests to drain after `Shutdown` is called. | +| `shutdown.pre_shutdown_delay` | `2s` | Pre-shutdown delay so load balancers see the readiness flip before connections start failing. | +| `tls.enabled` | `false` | If true, terminate TLS in-process. Most deployments terminate at the LB; leave off. | +| `tls.cert_file` / `tls.key_file` | `""` | PEM paths when `tls.enabled` is true. | + +## `auth` + +| Key | Default | Description | +| --- | --- | --- | +| `allow_anonymous` | `false` | Falls back to anonymous identity when no inbound credential matches. | +| `require_for_api` | `false` | **Reserved** for per-surface gating; the inbound chain is currently gated by `allow_anonymous` alone. The shipped `live.yaml` opts in to `true`. | +| `require_for_portal` | `false` | **Reserved**, same shape. The shipped `live.yaml` opts in to `true`. | + +## `api_keys` + +Inbound API-key authentication. Both the file source and the DB source +can be enabled; the chain consults file first, then DB. + +| Key | Default | Description | +| --- | --- | --- | +| `header_name` | `"X-API-Key"` | Header the gateway sends the credential in. Customize per connection in Plexara if you use a different name. | +| `query_param_name` | `"api_key"` | Query param fallback for `auth_mode: api_key, placement: query`. | +| `file[]` | `[]` | Static list of `{name, key, description}` triples. Constant-time compare. | +| `db.enabled` | `false` | Enable the bcrypt-hashed Postgres-backed key store. Requires `database.url`. | + +## `bearer` + +Static bearer-token authentication for `auth_mode: bearer` connections. + +| Key | Default | Description | +| --- | --- | --- | +| `tokens[]` | `[]` | List of `{name, token, description}` triples. Constant-time compare. | + +## `oidc` + +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. + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | `false` | Turn on the OIDC validator. | +| `issuer` | `""` | Issuer URL; required when `enabled`. JWKS fetched from `${issuer}/.well-known/openid-configuration`. | +| `audience` | `""` | Expected `aud` claim. | +| `allowed_clients` | `[]` | Whitelist of `client_id` claim values. | +| `clock_skew_seconds` | `30` | Tolerance applied to `exp` and `nbf`. | +| `jwks_cache_ttl` | `1h` | How long to cache fetched JWKS. | +| `skip_signature_verification` | `false` | Disables signature checking. Requires `APITEST_INSECURE=1`; never use in production. | + +## `database` + +Postgres connection pool. Required when `audit.enabled` or +`api_keys.db.enabled` is true; otherwise optional. + +| Key | Default | Description | +| --- | --- | --- | +| `url` | `""` | PostgreSQL DSN. Migrations run on boot via golang-migrate. | +| `max_open_conns` | `25` | `pgxpool.Config.MaxConns`. | +| `max_idle_conns` | `5` | `pgxpool.Config.MinConns`. | +| `conn_max_lifetime` | `1h` | `pgxpool.Config.MaxConnLifetime`. | + +## `audit` + +Per-request audit logging. The middleware writes one +`audit_events` row plus an optional `audit_payloads` sibling row per +inbound request (skipping `/healthz`, `/readyz`, well-known endpoints, +and the portal auth flow). + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | `false` | Master switch. When false, the noop logger is used. | +| `retention_days` | `7` | Lower than mcp-test's 30 because api-test responses can be much larger; export endpoints emit 100 MiB bodies. | +| `redact_keys` | (sane default) | Substrings (case-insensitive) matched against header names and query keys; matches are written as `[redacted]`. | +| `capture_payloads` | `true` | Write the `audit_payloads` sibling row with bodies and headers. | +| `capture_headers` | `true` | Include redacted headers in the payload row. | +| `max_payload_bytes` | `1 MiB` | Per-side body capture cap. Larger bodies are truncated and the matching `_truncated` flag is set. | + +Default `redact_keys`: + +```yaml +- password +- token +- secret +- authorization +- api_key +- api-key +- credentials +- bearer +- cookie +- jwt +- session_id +- private_key +- passwd +``` + +## `portal` + +The embedded React SPA + portal API. Lands in M3. + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | `false` | Mount the portal under `/portal/` and the portal API under `/api/v1/portal/*`. | +| `cookie_name` | `"api_test_session"` | Session cookie name. | +| `cookie_secret` | `""` | HMAC secret. Required when `enabled`. | +| `cookie_secure` | `false` | Set the `Secure` cookie attribute. Enable in TLS deployments. | +| `oidc_redirect_path` | `"/portal/auth/callback"` | OIDC PKCE callback path. | + +## `endpoints` + +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+ | + +## `plexara.register` + +Optional self-registration with a running Plexara instance on startup. +Default off; for one-shot setup use the curl flow in +[Register with Plexara](../getting-started/register-with-plexara.md). + +| Key | Default | Description | +| --- | --- | --- | +| `enabled` | `false` | POST a connection definition to `admin_url` on startup. | +| `admin_url` | `""` | Plexara admin API URL. Required when `enabled`. | +| `auth_header` | `""` | Authorization header value (e.g. `Bearer `). | +| `connection_name` | `"api-test"` | Connection name to register under. | diff --git a/docs/endpoints/data.md b/docs/endpoints/data.md new file mode 100644 index 0000000..463ed34 --- /dev/null +++ b/docs/endpoints/data.md @@ -0,0 +1,137 @@ +--- +title: Data endpoints +description: /v1/fixed/{key}, /v1/sized?bytes=N, /v1/lorem?words=N&seed=S — deterministic outputs for testing dedup, response-size handling, and caching boundaries. +--- + +# Data + +The `data` group returns deterministic bodies. Same input → same output, +forever. Useful for cache hits, dedup logic, bitwise equality +assertions, and gateway response-size handling. + +Source: [`pkg/endpoints/data`](https://github.com/plexara/api-test/tree/main/pkg/endpoints/data). + +## `fixed` + +```http +GET /v1/fixed/{key} +``` + +Returns a body deterministically derived from the `{key}` path +parameter. Same key → same body, every time. + +Response (200): + +```json +{ + "key": "hello", + "hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "body": "fixed[hello]: 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" +} +``` + +`hash` is `sha256(key)` rendered hex; `body` is a human-readable +restatement. Use either for assertions. + +### What it proves + +- Cache hit/miss behavior in the gateway. If the gateway caches by + `(method, path)`, two calls to `/v1/fixed/hello` should produce one + upstream call (the second a cache hit). +- Dedup logic. If the gateway dedups concurrent identical requests, + the audit log shows one upstream call for N concurrent client calls. +- Response equality across runs. Snapshots remain valid forever. + +### Curl + +```bash +curl -s http://localhost:8080/v1/fixed/hello -H "X-API-Key: $KEY" | jq +curl -s http://localhost:8080/v1/fixed/world -H "X-API-Key: $KEY" | jq +``` + +## `sized` + +```http +GET /v1/sized?bytes=N +``` + +Returns exactly `N` bytes in the `body` field of a JSON envelope. The +content is the lowercase ASCII alphabet repeated; not random. + +Response (200): + +```json +{ "bytes": 64, "body": "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl" } +``` + +Bounds: + +- `0 <= bytes <= 32 MiB` (32×1024×1024). Larger sizes belong on the + export endpoint group (M4+), which streams to the asset store + instead of allocating in memory. +- `bytes < 0` or non-integer → 400. + +### What it proves + +- Gateway response-size limits. Set `max_response_bytes` on the Plexara + connection to 1 MiB and call `?bytes=2097152` — the gateway should + surface the truncation hint without crashing. +- Streaming buffer behavior. Large bodies are written via streaming + `Write` calls, not buffered up front, so the gateway's reader has to + cope with multi-chunk reads. +- Audit truncation. With `audit.max_payload_bytes: 1048576`, a 2 MiB + response should write `response_truncated: true`. + +### Curl + +```bash +# Exactly 1 KiB +curl -s "http://localhost:8080/v1/sized?bytes=1024" -H "X-API-Key: $KEY" | jq -r '.body | length' +# → 1024 + +# Force a 400 +curl -s -o - -w "STATUS=%{http_code}\n" "http://localhost:8080/v1/sized?bytes=abc" -H "X-API-Key: $KEY" +``` + +## `lorem` + +```http +GET /v1/lorem?words=N&seed=S +``` + +Returns `N` words of seeded fake-Latin prose. Same seed → same body. +Without a seed, every call differs (PRNG seeded from +non-deterministic state). + +Response (200): + +```json +{ "words": 5, "body": "Ad excepteur anim sint laborum." } +``` + +Defaults and caps: + +- `words <= 0` (omitted) → defaults to 50. +- `words > 5000` → capped at 5000. +- `seed` is hashed (FNV-64) twice with different salts to seed a + PCG generator, so different seeds give independent streams. + +### What it proves + +- Reproducible "real-looking" content fixtures. Useful for snapshots + that depend on natural-language byte distributions (compression + testing, text-content gateway middlewares). +- Determinism with a non-trivial body. Unlike `fixed`, the body + contains spaces and punctuation, so byte-equal assertions exercise + more of the response path. + +### Curl + +```bash +curl -s "http://localhost:8080/v1/lorem?words=20&seed=cat" -H "X-API-Key: $KEY" | jq +curl -s "http://localhost:8080/v1/lorem?words=20&seed=cat" -H "X-API-Key: $KEY" | jq +# Same output both times. + +curl -s "http://localhost:8080/v1/lorem?words=20&seed=dog" -H "X-API-Key: $KEY" | jq +# Different output (different seed). +``` diff --git a/docs/endpoints/echo.md b/docs/endpoints/echo.md new file mode 100644 index 0000000..a2ac35b --- /dev/null +++ b/docs/endpoints/echo.md @@ -0,0 +1,108 @@ +--- +title: Echo endpoint +description: ANY /v1/echo — generic catch-all that returns the request verbatim with auth headers redacted. +--- + +# Echo + +A generic catch-all that returns the inbound request verbatim. Useful +for ad-hoc Try-It from the portal, debugging a Plexara connection +mid-flight without touching api-test code, or as a smoke test for any +HTTP behavior the more specific endpoint groups don't cover yet. + +Source: [`pkg/endpoints/echo`](https://github.com/plexara/api-test/tree/main/pkg/endpoints/echo). + +## Routes + +```http +GET /v1/echo +POST /v1/echo +PUT /v1/echo +PATCH /v1/echo +DELETE /v1/echo +HEAD /v1/echo +``` + +The same handler is mounted under every method so the OpenAPI doc lists +each verb separately (which lets the Plexara `api_list_endpoints` tool +see them as discoverable operations). + +## Response + +```json +{ + "method": "POST", + "path": "/v1/echo", + "query": { "foo": ["1", "2"], "bar": ["baz"] }, + "headers": { + "Accept": ["*/*"], + "Content-Type": ["application/json"], + "X-Trace-Id": ["custom-1"], + "Authorization": ["[redacted]"] + }, + "body": { "hello": "world", "n": 42 }, + "body_size": 24 +} +``` + +Field-by-field: + +- `method` — verbatim. +- `path` — verbatim. (`{key}` would appear as `{key}` literally if you + hit `/v1/echo/{key}`; this endpoint is mounted at the literal `/v1/echo`.) +- `query` — multi-valued; `?foo=1&foo=2` round-trips as `["1","2"]`. +- `headers` — canonical-cased, multi-valued, with redaction applied + per `audit.redact_keys`. +- `body` — parsed as JSON if the body decodes; falls through to + `body_raw_text` for non-JSON bodies. +- `body_size` — raw byte length, before parsing. + +`HEAD /v1/echo` returns 200 with no body, as required by RFC 9110. + +## What it proves + +- Method support. Every method round-trips; if the gateway has + per-method policy (e.g. block `DELETE`), you can verify the gateway + correctly refuses without us seeing the request. +- Body forwarding. `Content-Type: application/json` bodies are parsed + and re-rendered; the response is byte-identical to the gateway's + serialization of the parsed object. +- Header pass-through. Same as `/v1/headers` but with the request body + also visible. +- Query encoding. Multi-valued query params, URL-encoded special + characters, empty values — all visible. + +## Curl + +```bash +KEY=$APITEST_DEV_KEY + +# GET with query +curl -s "http://localhost:8080/v1/echo?foo=1&foo=2&bar=baz" \ + -H "X-API-Key: $KEY" -H "X-Trace-Id: t1" | jq + +# POST JSON +curl -s -X POST http://localhost:8080/v1/echo \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: application/json" \ + -d '{"hello":"world","nested":{"a":[1,2,3]}}' | jq + +# PUT plain text +curl -s -X PUT http://localhost:8080/v1/echo \ + -H "X-API-Key: $KEY" \ + -H "Content-Type: text/plain" \ + --data 'not json: just bytes' | jq + +# DELETE +curl -s -X DELETE http://localhost:8080/v1/echo -H "X-API-Key: $KEY" | jq + +# HEAD +curl -s -I http://localhost:8080/v1/echo -H "X-API-Key: $KEY" +``` + +## Body cap + +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+). diff --git a/docs/endpoints/failure.md b/docs/endpoints/failure.md new file mode 100644 index 0000000..38b1a7d --- /dev/null +++ b/docs/endpoints/failure.md @@ -0,0 +1,166 @@ +--- +title: Failure mode endpoints +description: /v1/status/{code}, /v1/slow?ms=N, /v1/flaky?fail_rate=&seed=&call_id= — controlled failure modes for retry and timeout policy testing. +--- + +# Failure modes + +The `failure` group produces controlled, predictable failures. Use them +to exercise the gateway's retry policy, timeout enforcement, and +error-surfacing behavior without depending on real upstreams that +misbehave only occasionally. + +Source: [`pkg/endpoints/failure`](https://github.com/plexara/api-test/tree/main/pkg/endpoints/failure). + +## `status` + +```http +GET /v1/status/{code} +``` + +Returns the supplied HTTP status code (httpbin-style). The body is a +small JSON envelope documenting what was returned. + +Response: + +```http +HTTP/1.1 503 Service Unavailable +Content-Type: application/json + +{ "status": 503, "message": "Service Unavailable" } +``` + +Bounds: `100 <= code <= 599`. Anything outside the valid HTTP status +range returns 400. + +### What it proves + +- Status code surfacing. The gateway's `api_invoke_endpoint` should + pass `status: 503` through verbatim, not collapse it into a tool-level + error. +- 4xx vs 5xx differentiation. A 401 from upstream is meaningfully + different from a 503; the gateway audit row should reflect that. +- Audit `success` flag. api-test's audit middleware marks + `success: status >= 200 && status < 400`, so a 503 row shows + `success: false` while a 304 shows `success: true`. + +### Curl + +```bash +for code in 200 201 204 301 400 401 404 418 429 500 502 503 504; do + echo -n "code=$code → " + curl -s -o - -w "%{http_code}\n" -H "X-API-Key: $KEY" \ + http://localhost:8080/v1/status/$code | tail -1 +done +``` + +## `slow` + +```http +GET /v1/slow?ms=N +``` + +Sleeps for `N` milliseconds before responding. Honors context +cancellation (HTTP/2 RST_STREAM, client disconnect, gateway timeout +firing). + +Response (200, after the wait): + +```json +{ "slept_ms": 1003, "requested_ms": 1000 } +``` + +Cancelled response (499; non-standard "client closed" status): + +```json +{ "slept_ms": 47, "cancelled": true, "requested_ms": 5000 } +``` + +Bounds: + +- `ms <= 0` → 0 (immediate response). +- `ms > 60000` → capped at 60000 (60s). + +### What it proves + +- Gateway connect-timeout vs call-timeout enforcement. Plexara's per- + connection `connect_timeout` (default 10s, TCP+TLS) should *not* + fire on `?ms=8000` (the connection is already established); the + per-connection `call_timeout` (default 60s) and the per-call + `timeout_seconds` argument are what should fire. +- Context propagation. When the gateway aborts because its timer + fires, api-test should observe `r.Context().Done()` and return + promptly with `cancelled: true`. + +### Curl + +```bash +# 1.2s upstream +time curl -s "http://localhost:8080/v1/slow?ms=1200" -H "X-API-Key: $KEY" | jq + +# Provoke client cancel: ^C after a moment. +curl -s --max-time 1 "http://localhost:8080/v1/slow?ms=10000" -H "X-API-Key: $KEY" +# api-test will return 499 with cancelled: true. +``` + +## `flaky` + +```http +GET /v1/flaky?fail_rate=R&seed=S&call_id=N +``` + +Returns 200 or 503 based on a deterministic roll. Same `(seed, call_id)` +always produces the same outcome. + +Response (200): + +```json +{ "failed": false, "roll": 0.234, "fail_rate": 0.5 } +``` + +Response (503): + +```json +{ "failed": true, "roll": 0.812, "fail_rate": 0.5 } +``` + +Inputs: + +- `fail_rate` — clamped to `[0, 1]`. `0` always passes, `1` always + fails. +- `seed` — string fed into a PCG generator (FNV-64 twice with different + salts). +- `call_id` — integer combined with seed; pass `0..N` to walk a + reproducible failure pattern. + +When `seed` is empty, the PRNG seeds from non-deterministic state and +results vary per call. For test fixtures, *always* set a seed. + +### What it proves + +- Retry policy. Set `fail_rate=0.5, seed=demo, call_id=1` — that call + has a fixed outcome. Replay it through the gateway and the same + retry path fires every time. +- Reproducible failure rates over a sample. Walk `call_id=0..99` with + a fixed seed and rate; the failure count is deterministic across + runs (≈ rate × 100, depending on PRNG roll distribution). +- Error categorization. Whatever the gateway tags 503s as in its own + metrics is exercisable here. + +### Curl + +```bash +# Always-fail (rate=1) +curl -s -o - -w "STATUS=%{http_code}\n" \ + "http://localhost:8080/v1/flaky?fail_rate=1&seed=demo&call_id=1" \ + -H "X-API-Key: $KEY" +# → 503 + +# Reproducibility +for i in {1..5}; do + curl -s -o - -w " call_id=$i status=%{http_code}\n" \ + "http://localhost:8080/v1/flaky?fail_rate=0.4&seed=demo&call_id=$i" \ + -H "X-API-Key: $KEY" >/dev/null +done +# Re-run; identical statuses. +``` diff --git a/docs/endpoints/identity.md b/docs/endpoints/identity.md new file mode 100644 index 0000000..827284e --- /dev/null +++ b/docs/endpoints/identity.md @@ -0,0 +1,125 @@ +--- +title: Identity endpoints +description: /v1/whoami and /v1/headers — verify the gateway forwards identity and headers (with redaction) the way you expect. +--- + +# Identity + +The `identity` group surfaces what the inbound auth chain resolved and +what HTTP headers reached api-test. The bread-and-butter for verifying +gateway pass-through behavior. + +Source: [`pkg/endpoints/identity`](https://github.com/plexara/api-test/tree/main/pkg/endpoints/identity). + +## `whoami` + +Returns the resolved inbound identity for the calling request. + +```http +GET /v1/whoami +``` + +Response (200): + +```json +{ + "subject": "devkey", + "auth_type": "apikey", + "key_name": "devkey" +} +``` + +Fields: + +| Field | Description | +| --- | --- | +| `subject` | Canonical principal id: API key name, bearer token name, or OIDC `sub` claim. Empty for anonymous. | +| `email` | Best-effort, OIDC only. | +| `auth_type` | `anonymous`, `apikey`, `bearer`, or `oauth2`. | +| `key_name` | Display name of the matched credential. | +| `scopes` | OAuth2 scopes (or roles), if any. | +| `claims` | Raw OIDC claim map for debugging. | + +### What it proves + +- The gateway forwarded the right credential under the right transport + (Bearer vs X-API-Key vs query param). +- The credential matched the expected api-test entry. +- `auth_type` is what the connection registration promised; a typo'd + `oauth2_authorization_code` connection that comes through as `apikey` + is a misconfiguration the response surfaces immediately. + +### Curl + +```bash +KEY=$APITEST_DEV_KEY + +# X-API-Key header +curl -s http://localhost:8080/v1/whoami -H "X-API-Key: $KEY" | jq + +# Query placement +curl -s "http://localhost:8080/v1/whoami?api_key=$KEY" | jq + +# Bearer +curl -s http://localhost:8080/v1/whoami \ + -H "Authorization: Bearer $APITEST_DEV_BEARER" | jq + +# Anonymous (only when auth.allow_anonymous: true) +curl -s http://localhost:8080/v1/whoami | jq +``` + +## `headers` + +Returns every inbound HTTP header the request carried, with redaction +applied to anything matching `audit.redact_keys`. + +```http +GET /v1/headers +``` + +Response (200): + +```json +{ + "headers": { + "Accept": ["*/*"], + "X-Request-Id": ["8c5b...3f7a"], + "X-Trace-Id": ["custom-trace-1"], + "Authorization": ["[redacted]"], + "X-Api-Key": ["[redacted]"] + }, + "count": 5 +} +``` + +Header names are normalized to canonical Go form (`X-API-Key` → +`X-Api-Key`); values are returned as arrays so multi-value headers +(`Accept-Language: en, fr`) round-trip faithfully. + +### What it proves + +- The gateway forwarded the headers you expected (custom tracing + headers, content-type, accept). +- The gateway *added* headers you expected (`X-Request-Id`, + `X-Forwarded-For`). +- The gateway *stripped* headers you expected stripped (depends on + policy — Plexara's behavior is documented separately). +- Sensitive headers are redacted before they land in api-test's audit + log; the response body is the same redacted form, so a screenshot + doesn't leak the credential. + +### Curl + +```bash +curl -s http://localhost:8080/v1/headers \ + -H "X-API-Key: $APITEST_DEV_KEY" \ + -H "X-Trace-Id: trace-from-test" \ + -H "X-Custom-Vendor: anything" | jq +``` + +### Pairing with the audit log + +The `audit_payloads.request_headers` JSONB column carries the same +redacted view. Cross-check that what the response shows matches what +the audit log stored — they're produced by the same `SanitizeHeaders` +helper, so any drift is a bug. diff --git a/docs/endpoints/overview.md b/docs/endpoints/overview.md new file mode 100644 index 0000000..263c306 --- /dev/null +++ b/docs/endpoints/overview.md @@ -0,0 +1,78 @@ +--- +title: Endpoints overview +description: Catalog of every api-test endpoint group, the gateway behavior each one exercises, and the determinism contract. +--- + +# Endpoints overview + +api-test groups its routes by what gateway behavior they exercise. Each +group is a small Go package under +[`pkg/endpoints`](https://github.com/plexara/api-test/tree/main/pkg/endpoints) +implementing a tiny `Endpoints` interface; the registry composes them +into the mux and into the published OpenAPI document. + +## Determinism contract + +Same input → same output. Forever. + +- Endpoints that take a key, path parameter, or seed are pure functions + of those inputs. The body never changes. +- Endpoints that take a status code or duration produce exactly the + requested behavior; the gateway sees what it asked for. +- Endpoints that fail "randomly" take a `(seed, call_id)` pair so the + same inputs always produce the same outcome. + +This makes assertions falsifiable. If the gateway forwarded the right +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`. | + +## Toggling groups + +Every group is gated by `endpoints..enabled` in config. Disable +a group and its routes vanish from the mux *and* from the OpenAPI +document. + +```yaml +endpoints: + identity: { enabled: true } + data: { enabled: true } + failure: { enabled: false } # off + echo: { enabled: true } +``` + +This is useful for narrowing what a gateway connection sees — e.g., +register two api-test connections, one with only `failure` enabled (for +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 +document, so registering api-test with its OpenAPI spec inline gives +gateway callers a discoverable catalog. + +A boot-time self-check (`pkg/oapi/selfcheck.go`) verifies every route +the registry declares actually has a mux handler, and vice-versa, so +the doc can't drift from the served routes. + +## Audit + +Every request to a `/v1/*` endpoint lands in the audit log with the +resolved identity, method, path, status, durations, and (configurably) +the redacted headers and bodies. Health and well-known endpoints sit +outside the audit middleware and don't generate rows. + +See [Audit log](../operations/audit.md) for schema and retention. diff --git a/docs/fonts/dm-sans-latin-ext.woff2 b/docs/fonts/dm-sans-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9443ff9f45d3844e82c20f630bc5a0266bb7402e GIT binary patch literal 31312 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 X+@%Obd_I literal 0 HcmV?d00001 diff --git a/docs/fonts/dm-sans-latin.woff2 b/docs/fonts/dm-sans-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9313206b1ff91d44a7f48bd0e631aacb563a84ca GIT binary patch literal 62556 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 CvSSwj literal 0 HcmV?d00001 diff --git a/docs/fonts/outfit-latin.woff2 b/docs/fonts/outfit-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..48aae950fc21da407d5577e7ce63e028bf9bb205 GIT binary patch literal 32228 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 z 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 CX=#W6 literal 0 HcmV?d00001 diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..4bdf32a --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,85 @@ +--- +title: Installation +description: Binary download, container image (GHCR), go install, and building from source. Pick whichever flavor matches your deployment. +--- + +# Installation + +api-test ships as a single static Go binary plus an OCI container image +on GHCR. Pick whichever fits your deployment. + +## Container image (recommended) + +Distroless multi-arch image, signed via cosign on tag. + +```bash +docker pull ghcr.io/plexara/api-test:latest + +docker run --rm -p 8080:8080 \ + -v $(pwd)/configs/api-test.dev.yaml:/etc/api-test/api-test.yaml:ro \ + ghcr.io/plexara/api-test:latest --config /etc/api-test/api-test.yaml +``` + +Tags: `latest` (HEAD of `main`), `vX.Y.Z` (released versions), +`vX.Y` (latest patch in a minor line), `vX` (latest minor in a major +line). Use `vX.Y.Z` for reproducible builds; use `vX` for latest +non-breaking updates. + +## Binary download + +Pre-built binaries for linux/amd64, linux/arm64, and darwin/arm64 on +the [releases page](https://github.com/plexara/api-test/releases). Each +release ships a SHA-256 checksum file and a cosign signature. + +```bash +# Linux amd64 +curl -sLO https://github.com/plexara/api-test/releases/latest/download/api-test_Linux_x86_64.tar.gz +tar xzf api-test_Linux_x86_64.tar.gz +./api-test --version +``` + +## go install + +If you have Go on your PATH: + +```bash +go install github.com/plexara/api-test/cmd/api-test@latest +``` + +The resulting binary lands in `$GOBIN` (or `$GOPATH/bin`). Version +metadata is `dev / none / unknown` because the build skipped goreleaser's +`-ldflags -X` plumbing; for an authoritative version, use a release +binary or the container image. + +## From source + +```bash +git clone https://github.com/plexara/api-test +cd api-test +make build # produces ./bin/api-test +./bin/api-test --version +``` + +`make build` stamps version, commit, and date into the binary via +`-ldflags -X`. Run `make help` to see the full target catalog. + +To build the container image locally: + +```bash +make docker +docker run --rm -p 8080:8080 api-test:vX.Y.Z-dirty --config /etc/api-test/api-test.yaml +``` + +The Dockerfile is a multi-stage build that compiles a static linux/amd64 +binary then copies it into `gcr.io/distroless/static-debian12:nonroot`. +The binary doubles as its own healthcheck (`--healthcheck`) so the image +doesn't need curl/wget. + +## What you need next + +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. diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md new file mode 100644 index 0000000..fcce13a --- /dev/null +++ b/docs/getting-started/overview.md @@ -0,0 +1,87 @@ +--- +title: Overview +description: What api-test is, who should use it, and why a separate HTTP test fixture matters when you're validating an API gateway. +--- + +# Overview + +api-test is an HTTP REST server you point an API gateway at as a +controllable upstream. Its endpoints are intentionally simple and +deterministic. Their job is not to compute anything useful; their job +is to make a gateway's behavior observable. + +The Plexara API gateway exposes three MCP tools to its callers +(`api_invoke_endpoint`, `api_list_endpoints`, `api_export`) and runs +each call through a registered HTTP connection. api-test is the upstream +behind that connection — the thing the gateway actually talks to. + +## Who should use it + +- **Plexara API gateway operators** validating connection registration, + auth-mode handling, redaction, pagination detection, error surfacing, + and timeout enforcement before hooking up production upstreams. +- **API gateway developers** (any vendor) needing a controllable + upstream that doesn't fight back: every endpoint is documented, every + body is reproducible, every failure mode is opt-in. +- **Anyone building HTTP API integrations** who wants a reference for + audit logging, multi-mode inbound auth, in-tree OpenAPI generation, + embedded portal patterns. The code is small and Apache 2.0. + +## What's in the box + +- **Endpoint groups** for identity (whoami, headers), deterministic + data (fixed/sized/lorem), failure modes (status/slow/flaky), echo, and + — landing in later releases — pagination styles, method matrix, + security probes, large/streamed responses for `api_export`. +- **Inbound auth chain**: file API keys (header or query placement), + bcrypt-hashed Postgres keys, static bearer tokens, and OIDC JWT + validation. Matches every credential the Plexara gateway forwards. +- **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, + 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 + from the registered endpoint metadata so it can't drift from the + served routes. +- **Operational ergonomics**: `--healthcheck` self-probe, graceful + shutdown with a configurable drain window, `${VAR:-default}` env + interpolation, distroless container image. + +## Why a separate fixture + +You can't reliably test an API gateway against real upstreams. They +mutate. They rate-limit. Their pagination cursors expire. Their auth +tokens rotate. Their failure modes are accidental and rare. + +api-test gives you the inverse of all of that: + +- Same input → same output. Forever. Cache hits, dedup logic, + bitwise comparison all work. +- Failures on demand. Want a 503? Hit `/v1/status/503`. Want a 1.2s + upstream? Hit `/v1/slow?ms=1200`. Want intermittent failure at a + reproducible rate? Hit `/v1/flaky?fail_rate=0.5&seed=demo&call_id=N`. +- Pagination on tap. One endpoint per cursor style the gateway + recognizes. Negative tests included. +- Identity and header echoes. Confirm exactly what the gateway forwarded + (and what it stripped) without consulting upstream logs you don't + control. + +## Sister project: mcp-test + +[mcp-test](https://mcp-test.plexara.io/) is the same idea for the +**MCP gateway** half of Plexara: a controllable MCP server fixture with +twelve test tools, three auth methods, the same audit log surface, the +same portal shell. Use api-test to validate API-gateway behavior; +use mcp-test to validate MCP-gateway behavior. + +## Next + +- [Installation](installation.md) — download the binary or pull the + container image. +- [Quickstart](quickstart.md) — `make dev` runs the binary in + anonymous mode today; the full Postgres + Keycloak + portal stack + lands with M3. +- [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 new file mode 100644 index 0000000..24c79ef --- /dev/null +++ b/docs/getting-started/quickstart.md @@ -0,0 +1,138 @@ +--- +title: Quickstart +description: Get the api-test binary running in under a minute and hit every endpoint with curl. +--- + +# Quickstart + +```bash +git clone https://github.com/plexara/api-test +cd api-test +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 +`/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, +`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 +`configs/api-test.live.yaml`. The +[milestone status](../reference/releases.md) tracks where each piece +stands. + +When it's up (today, anonymous mode): + +
+ +
+
http://localhost:8080/v1/...
+
Endpoint groups. See [Endpoints overview](../endpoints/overview.md).
+
+ +
+
http://localhost:8080/healthz
+
Liveness probe.
+
+ +
+
http://localhost:8080/portal/
+
Portal (M3+). Sign in with `dev` / `dev` (OIDC) or paste an API key.
+
+ +
+
http://localhost:8081/
+
Keycloak admin console (M3+, `admin` / `admin`).
+
+ +
+ +## Auth-enabled iteration + +To exercise the inbound auth chain without standing up Keycloak, run the +binary against a config that enables `api_keys.file` and/or +`bearer.tokens` while leaving `audit.enabled: false`: + +```bash +cat > /tmp/api-test-auth.yaml <<'EOF' +auth: + allow_anonymous: false +api_keys: + file: + - { name: "devkey", key: "dev-secret-1" } +bearer: + tokens: + - { name: "devbearer", token: "dev-bearer-1" } +endpoints: + identity: { enabled: true } + data: { enabled: true } + failure: { enabled: true } + echo: { enabled: true } +EOF + +go run ./cmd/api-test --config /tmp/api-test-auth.yaml +``` + +`make dev-secrets` (already in the Makefile) writes a gitignored +`.env.dev` with random `APITEST_DEV_KEY` / `APITEST_DEV_BEARER` / +`APITEST_COOKIE_SECRET` values; M3's full `make dev` will source it +automatically. + +## Verify it works + +A quick curl smoke test against the running server (anonymous mode): + +```bash +# Self-describing root +curl -s http://localhost:8080/ | jq + +# Liveness +curl -s http://localhost:8080/healthz + +# Identity (anonymous) +curl -s http://localhost:8080/v1/whoami | jq + +# Deterministic fixture +curl -s http://localhost:8080/v1/fixed/hello | jq + +# Exact-N-bytes response +curl -s 'http://localhost:8080/v1/sized?bytes=64' | jq + +# Seeded lorem +curl -s 'http://localhost:8080/v1/lorem?words=10&seed=cat' | jq + +# Forced failure +curl -s -o - -w "STATUS=%{http_code}\n" http://localhost:8080/v1/status/418 + +# Echo +curl -s -X POST http://localhost:8080/v1/echo \ + -H 'Content-Type: application/json' \ + -d '{"hello":"world"}' | jq +``` + +In the auth-enabled config above, prefix every endpoint call with +`-H "X-API-Key: dev-secret-1"` (or `?api_key=dev-secret-1` in the +query string), or `-H "Authorization: Bearer dev-bearer-1"`. + +## Stop the stack + +In the foreground binary's terminal: `Ctrl-C`. Once M3 lands, `make +dev-down` will also tear down the compose stack. + +## Next + +- [Register with Plexara](register-with-plexara.md) — wire api-test in + as a connection in a running Plexara instance. +- [Endpoints overview](../endpoints/overview.md) — catalog of every + route and what gateway behavior it exercises. +- [YAML reference](../configuration/reference.md) — every config key + with its default and environment override. +- [Testing a gateway](../operations/gateway-testing.md) — patterns for + asserting on the Plexara API gateway end-to-end. diff --git a/docs/getting-started/register-with-plexara.md b/docs/getting-started/register-with-plexara.md new file mode 100644 index 0000000..155c2e9 --- /dev/null +++ b/docs/getting-started/register-with-plexara.md @@ -0,0 +1,217 @@ +--- +title: Register with Plexara +description: Wire api-test in as a connection in a running Plexara API gateway, with one example per supported auth mode. +--- + +# Register with Plexara + +To use api-test as the upstream for your Plexara API gateway, register +it as a connection. Plexara stores the connection definition (base URL, +auth mode, optional OpenAPI spec) and exposes it to its callers via +`api_invoke_endpoint`, `api_list_endpoints`, and `api_export`. + +This page documents how to register api-test under every inbound auth +mode it supports. The Plexara admin API surface used here is described +in Plexara's own docs; api-test ships an example payload at +[`examples/plexara-connection.yaml`](https://github.com/plexara/api-test/blob/main/examples/plexara-connection.yaml). + +The examples below assume: + +- api-test is reachable at `http://api-test.local:8080` (substitute your + hostname). +- The Plexara admin API lives at `https://plexara.example.com/api/v1/admin/api-gateway/connections`. +- You have an admin token in `$PLEXARA_ADMIN_TOKEN`. + +## Auth mode: `none` + +The simplest case. No credential is sent; api-test must run with +`auth.allow_anonymous: true`. Only useful for development. + +```bash +curl -s -X POST "$PLEXARA_ADMIN/connections" \ + -H "Authorization: Bearer $PLEXARA_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "api-test-anon", + "base_url": "http://api-test.local:8080", + "auth_mode": "none" + }' +``` + +## Auth mode: `bearer` + +Plexara forwards `Authorization: Bearer `. api-test validates +against the `bearer.tokens` list in its config (or against an OIDC +issuer if `oidc.enabled` is true; see below). + +```bash +curl -s -X POST "$PLEXARA_ADMIN/connections" \ + -H "Authorization: Bearer $PLEXARA_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "api-test-bearer", + "base_url": "http://api-test.local:8080", + "auth_mode": "bearer", + "credential": "'"$APITEST_DEV_BEARER"'" + }' +``` + +api-test config: + +```yaml +bearer: + tokens: + - { name: "devbearer", token: "${APITEST_DEV_BEARER}" } +``` + +## Auth mode: `api_key` (header) + +Plexara forwards the credential in a header (default `X-API-Key`, +customizable per connection). api-test validates against the +`api_keys.file` list and/or the bcrypt-hashed Postgres store. + +```bash +curl -s -X POST "$PLEXARA_ADMIN/connections" \ + -H "Authorization: Bearer $PLEXARA_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "api-test-apikey-header", + "base_url": "http://api-test.local:8080", + "auth_mode": "api_key", + "api_key_placement": "header", + "api_key_header": "X-API-Key", + "credential": "'"$APITEST_DEV_KEY"'" + }' +``` + +## Auth mode: `api_key` (query) + +Plexara appends `?api_key=...` (or another configured query param) to +every request. api-test reads the same param. + +```bash +curl -s -X POST "$PLEXARA_ADMIN/connections" \ + -H "Authorization: Bearer $PLEXARA_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "api-test-apikey-query", + "base_url": "http://api-test.local:8080", + "auth_mode": "api_key", + "api_key_placement": "query", + "api_key_param": "api_key", + "credential": "'"$APITEST_DEV_KEY"'" + }' +``` + +## Auth mode: `oauth2_client_credentials` + +Plexara performs the OAuth2 client-credentials exchange against the +configured token endpoint, then forwards the resulting JWT to api-test +as `Authorization: Bearer `. api-test validates the JWT against +the IdP's JWKS (configured via `oidc.issuer`). + +```bash +curl -s -X POST "$PLEXARA_ADMIN/connections" \ + -H "Authorization: Bearer $PLEXARA_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "api-test-oauth-cc", + "base_url": "http://api-test.local:8080", + "auth_mode": "oauth2_client_credentials", + "oauth2_token_url": "http://keycloak.local:8081/realms/api-test/protocol/openid-connect/token", + "oauth2_client_id": "plexara-cc", + "oauth2_client_secret": "...", + "oauth2_scopes": "api-test" + }' +``` + +api-test config: + +```yaml +oidc: + enabled: true + issuer: "http://keycloak.local:8081/realms/api-test" + audience: "api-test" + allowed_clients: ["plexara-cc"] +``` + +## Auth mode: `oauth2_authorization_code` + +Plexara performs an interactive auth-code flow against the IdP, persists +the resulting refresh token, and uses the access token to call api-test. +The api-test side is identical to client-credentials — JWT validation +against JWKS — but the gateway client is allowlisted as +`plexara-ac` instead of `plexara-cc`. + +```bash +# Step 1: initiate the OAuth flow (returns an authorization URL the +# operator opens in a browser). +curl -s -X POST "$PLEXARA_ADMIN/connections/api-test-oauth-ac/oauth-start" \ + -H "Authorization: Bearer $PLEXARA_ADMIN_TOKEN" +# → { "authorization_url": "https://.../authorize?...", "state": "..." } + +# Step 2: the operator opens the URL, completes login, and Keycloak +# redirects to Plexara's /api/v1/admin/api-gateway/oauth/callback +# endpoint. Plexara stores the resulting refresh token internally. +``` + +Update api-test's `oidc.allowed_clients` to include `plexara-ac`. + +## OpenAPI spec + +api-test publishes its OpenAPI 3.x document at +`/openapi.json` and `/openapi.yaml`. Pass the inline document into the +connection definition so the gateway's `api_list_endpoints` tool can +discover routes: + +```bash +SPEC=$(curl -s http://api-test.local:8080/openapi.json | jq -c .) + +curl -s -X POST "$PLEXARA_ADMIN/connections" \ + -H "Authorization: Bearer $PLEXARA_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"api-test\", + \"base_url\": \"http://api-test.local:8080\", + \"auth_mode\": \"api_key\", + \"api_key_placement\": \"header\", + \"credential\": \"$APITEST_DEV_KEY\", + \"openapi_spec\": $SPEC + }" +``` + +## Optional: self-registration + +api-test can register itself with Plexara on startup. Default off; flip +the switch in your config: + +```yaml +plexara: + register: + enabled: true + admin_url: "${PLEXARA_ADMIN_URL}" + auth_header: "Bearer ${PLEXARA_ADMIN_TOKEN}" + connection_name: "api-test" +``` + +This couples the fixture to the gateway, so leave it off for production +deployments and use the curl flow above for one-shot setup. + +## Verify + +Once registered, exercise api-test through Plexara from any MCP client: + +```text +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/whoami") + → { "subject": "...", "auth_type": "...", "key_name": "..." } + +api_list_endpoints(connection="api-test", query="status") + → operations matching "status" (e.g. /v1/status/{code}) + +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/status/503") + → status=503, body={"status":503,"message":"Service Unavailable"} +``` + +Cross-check by opening the api-test portal and watching the Audit page: +every Plexara call shows up with the resolved identity, method, path, +status, and latency. diff --git a/docs/images/logo.png b/docs/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c29990d70b1e6039bea3c4e6990d10a7f9233d0b GIT binary patch literal 25327 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 + + + + + + + + + + + + + + diff --git a/docs/images/og-card.png b/docs/images/og-card.png new file mode 100644 index 0000000000000000000000000000000000000000..02d93082b684d69ee0edd0262a370347035140f1 GIT binary patch literal 112491 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
+ +- :material-api:{ .lg } **Endpoint groups by behavior** + + Identity (whoami, headers), data (deterministic fixtures: fixed, + sized, lorem), failure modes (status, slow, flaky), and a generic + echo. More groups (pagination styles, methods, security probes, + export targets) land in upcoming releases. + +- :material-shield-key:{ .lg } **Real inbound auth, four ways** + + File-loaded API keys (header or query placement, constant-time + compare), bcrypt-hashed Postgres-backed keys, static bearer tokens, + and OIDC JWT validation against an external IdP. Matches every + auth mode the Plexara API gateway forwards. + +- :material-database-search:{ .lg } **Audit log of every request** + + Postgres-backed timeline with sanitized headers, query params, + request and response bodies, identity, latency, and status. Browse, + filter, and chart it in the embedded React portal. + +- :material-file-document-outline:{ .lg } **Self-describing OpenAPI** + + Every route is published in an OpenAPI 3.x document at + `/openapi.json`, generated in-tree from the same metadata the + portal uses, so the gateway's `api_list_endpoints` tool sees an + exact contract. + +- :material-page-next:{ .lg } **Pagination, the gateway recognizes** + + One endpoint per cursor style the gateway's pagination detector + recognizes: RFC 5988 `Link` headers, OData `@odata.nextLink`, and + the common cursor field variants. Negative tests included so + detection failures are falsifiable. + +- :material-alpha-p-circle:{ .lg } **By Plexara** + + [Plexara](https://plexara.io) is a unified MCP + API gateway with + configurable enrichment built in. api-test is what we use to + verify Plexara's API-gateway behavior end-to-end; we ship it as + OSS so anyone building API gateways can use the same fixture. + +
+ +## Why a separate test fixture? + +Validating an API gateway means changing one thing on the gateway (an +auth policy, a rate-limit rule, a header rewrite) and observing the +diff at the upstream. To do that observably, the upstream has to be +predictable. api-test gives you that: + +- Endpoints that return the same body for the same input + ([`/v1/fixed/{key}`](endpoints/data.md#fixed), + [`/v1/lorem`](endpoints/data.md#lorem) with a seed). +- Endpoints that fail on demand with any HTTP status + ([`/v1/status/{code}`](endpoints/failure.md#status)) and on a schedule + ([`/v1/slow`](endpoints/failure.md#slow), + [`/v1/flaky`](endpoints/failure.md#flaky) seeded for reproducibility). +- Endpoints that echo identity and headers so you can confirm what's + being forwarded ([`/v1/whoami`](endpoints/identity.md#whoami), + [`/v1/headers`](endpoints/identity.md#headers)). + +Pair that with the audit log and you can write end-to-end assertions +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). +- 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) + walks each supported auth mode. +- Validating a gateway? + [Testing a gateway](operations/gateway-testing.md) walks through + what each endpoint group proves. diff --git a/docs/javascripts/shots.js b/docs/javascripts/shots.js new file mode 100644 index 0000000..f5275e5 --- /dev/null +++ b/docs/javascripts/shots.js @@ -0,0 +1,233 @@ +/** + * Portal-screenshots carousel + lightbox. + * + * Carousel: slides the track via `transform: translateX` with a CSS + * transition for the actual animation; JS just tracks the current index + * and computes the offset. Wrap-around is implicit; buttons stay enabled. + * + * Lightbox: clicking any frame opens a near-full-screen modal showing + * the screenshot at full size. ESC closes; arrow keys step between + * shots. Backdrop click closes. Focus is captured on open and restored + * on close. + * + * Material's instant-nav can re-mount the page; we guard re-binding via + * data attributes on each subscribed root. + */ +(function () { + function init() { + document.querySelectorAll(".plex-shots").forEach(function (root) { + if (root.dataset.shotsBound === "1") return; + root.dataset.shotsBound = "1"; + + var track = root.querySelector("[data-shots-track]"); + var prev = root.querySelector("[data-shots-prev]"); + var next = root.querySelector("[data-shots-next]"); + if (!track || !prev || !next) return; + + var slides = Array.from(track.querySelectorAll(".plex-shots__slide")); + if (slides.length === 0) return; + + var counter = root.querySelector("[data-shots-counter]"); + var lightbox = root.querySelector("[data-shots-lightbox]"); + + var index = 0; + + function step() { + var styles = window.getComputedStyle(track); + var gap = parseFloat(styles.columnGap || styles.gap || "0") || 0; + return slides[0].getBoundingClientRect().width + gap; + } + + function apply() { + var offset = -index * step(); + track.style.transform = "translate3d(" + offset + "px, 0, 0)"; + slides.forEach(function (s, i) { + s.classList.toggle("is-active", i === index); + }); + if (counter) { + counter.innerHTML = + "" + String(index + 1).padStart(2, "0") + "" + + " / " + String(slides.length).padStart(2, "0"); + } + } + + function go(delta) { + index = (index + delta + slides.length) % slides.length; + apply(); + } + + next.addEventListener("click", function () { go(1); }); + prev.addEventListener("click", function () { go(-1); }); + + // Keyboard nav on the carousel itself: when focus lives within the + // stage, arrow keys advance. The lightbox owns its own keyboard + // handler when open, so this only fires while it's closed. + root.addEventListener("keydown", function (e) { + if (lightbox && !lightbox.hidden && lightbox.classList.contains("is-open")) return; + if (e.target.closest(".plex-shots__frame")) { + // Don't hijack Enter/Space on the frame button: those + // legitimately open the lightbox. + if (e.key === "Enter" || e.key === " ") return; + } + if (e.key === "ArrowLeft") { go(-1); } + else if (e.key === "ArrowRight") { go(1); } + }); + + // Keep the offset correct across resizes (slide widths are vw-based). + var resizeRaf; + window.addEventListener("resize", function () { + if (resizeRaf) cancelAnimationFrame(resizeRaf); + resizeRaf = requestAnimationFrame(apply); + }); + + apply(); + bindLightbox(root, slides, lightbox, function (newIndex) { + // When the lightbox navigates, sync the carousel so closing + // returns the user to the slide they were last viewing. + index = newIndex; + apply(); + }, function () { return index; }); + }); + } + + function bindLightbox(root, slides, lightbox, onIndex, getIndex) { + if (!lightbox || lightbox.dataset.lightboxBound === "1") return; + lightbox.dataset.lightboxBound = "1"; + + var imgLight = lightbox.querySelector("[data-lightbox-img-light]"); + var imgDark = lightbox.querySelector("[data-lightbox-img-dark]"); + var titleEl = lightbox.querySelector("#plex-lightbox-title"); + var bodyEl = lightbox.querySelector("[data-lightbox-body]"); + var countEl = lightbox.querySelector("[data-lightbox-count]"); + var prevBtn = lightbox.querySelector("[data-lightbox-prev]"); + var nextBtn = lightbox.querySelector("[data-lightbox-next]"); + // Note: data-shots-close lives on BOTH the backdrop div and the close + // button; the close button is what we focus on open (the backdrop + // isn't focusable). The dismiss handler iterates closeBtns to attach + // click handlers to both. + var closeBtns = lightbox.querySelectorAll("[data-shots-close]"); + var closeBtn = lightbox.querySelector("button[data-shots-close]"); + + var lastFocus = null; + var current = 0; + + function fillFromSlide(slideIndex) { + var slide = slides[slideIndex]; + var frame = slide && slide.querySelector(".plex-shots__frame"); + if (!frame) return; + current = slideIndex; + + var title = frame.getAttribute("data-zoom-title") || ""; + var body = frame.getAttribute("data-zoom-body") || ""; + var light = frame.getAttribute("data-zoom-light") || ""; + var dark = frame.getAttribute("data-zoom-dark") || ""; + + if (titleEl) titleEl.textContent = title; + if (bodyEl) bodyEl.textContent = body; + if (imgLight) { + imgLight.src = light; + imgLight.alt = "Portal " + title + " screen, light theme"; + } + if (imgDark) { + imgDark.src = dark; + imgDark.alt = "Portal " + title + " screen, dark theme"; + } + if (countEl) { + countEl.textContent = + String(slideIndex + 1).padStart(2, "0") + + " / " + + String(slides.length).padStart(2, "0"); + } + } + + function open(slideIndex) { + // If a previous close() is still in its 260ms post-fade hide + // window, cancel the pending hide so the freshly-opened modal + // doesn't get yanked back to hidden mid-display. + if (lightbox._hideTimer) { + clearTimeout(lightbox._hideTimer); + lightbox._hideTimer = null; + } + lastFocus = document.activeElement; + fillFromSlide(slideIndex); + lightbox.hidden = false; + // Force a reflow so the transition runs on the next paint. + void lightbox.offsetWidth; + lightbox.classList.add("is-open"); + document.documentElement.classList.add("plex-lightbox-open"); + // Focus the close button so ESC and Enter both work immediately + // and screen-reader users land inside the dialog. + if (closeBtn) closeBtn.focus(); + window.addEventListener("keydown", onKey, true); + } + + function close() { + lightbox.classList.remove("is-open"); + document.documentElement.classList.remove("plex-lightbox-open"); + window.removeEventListener("keydown", onKey, true); + // After the fade completes, hide outright so the modal can't + // catch tab focus or screen-reader attention. + lightbox._hideTimer = setTimeout(function () { + lightbox.hidden = true; + }, 260); + onIndex(current); + if (lastFocus && typeof lastFocus.focus === "function") { + lastFocus.focus(); + } + lastFocus = null; + } + + function onKey(e) { + if (e.key === "Escape") { + e.stopPropagation(); + close(); + } else if (e.key === "ArrowLeft") { + e.stopPropagation(); + e.preventDefault(); + fillFromSlide((current - 1 + slides.length) % slides.length); + } else if (e.key === "ArrowRight") { + e.stopPropagation(); + e.preventDefault(); + fillFromSlide((current + 1) % slides.length); + } + } + + // Click handlers on the carousel frames. + slides.forEach(function (slide, i) { + var frame = slide.querySelector("[data-shots-zoom]"); + if (!frame) return; + frame.addEventListener("click", function (e) { + e.preventDefault(); + // Sync the carousel to the clicked slide first so the + // background reflects the lightbox's starting frame. open() + // handles its own pending-hide-timer cleanup. + if (i !== getIndex()) onIndex(i); + open(i); + }); + }); + + // Lightbox buttons. + if (prevBtn) prevBtn.addEventListener("click", function () { + fillFromSlide((current - 1 + slides.length) % slides.length); + }); + if (nextBtn) nextBtn.addEventListener("click", function () { + fillFromSlide((current + 1) % slides.length); + }); + closeBtns.forEach(function (btn) { + btn.addEventListener("click", function (e) { + e.preventDefault(); + close(); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } + // Re-init on Material instant-nav. + if (typeof window.document$ !== "undefined" && window.document$.subscribe) { + window.document$.subscribe(init); + } +})(); diff --git a/docs/llms.txt b/docs/llms.txt new file mode 100644 index 0000000..fa544e9 --- /dev/null +++ b/docs/llms.txt @@ -0,0 +1,48 @@ +# api-test + +> A controllable HTTP REST fixture, built specifically as an upstream for testing API gateways end-to-end. Endpoint groups for identity, deterministic data, controlled failures, pagination styles, large/streamed responses, and SSRF probes; multiple inbound auth modes (file API keys, Postgres-backed bcrypt keys, static bearer, OIDC) matching what the Plexara API gateway sends; and a Postgres-backed audit log of every request. Open source by Plexara under Apache 2.0. + +The server itself is small and predictable on purpose; the value is the surface it gives a gateway operator to assert on. Same input always produces the same output. Failures happen exactly when asked. Every request lands in an audit log the embedded React portal can browse, filter, and chart. + +## Getting started + +- [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. +- [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 + +- [YAML reference](https://api-test.plexara.io/configuration/reference/): Every config key with default value and environment-variable override. +- [Environment variables](https://api-test.plexara.io/configuration/environment/): `APITEST_*` variables and how they map onto the YAML. +- [Authentication](https://api-test.plexara.io/configuration/auth/): Inbound auth modes (file API keys with header or query placement, Postgres-backed bcrypt keys, static bearer tokens, OIDC JWT validation) matching what the Plexara API gateway sends. +- [Database and migrations](https://api-test.plexara.io/configuration/database/): Postgres connection settings; migrations run on boot via golang-migrate. + +## Endpoints + +api-test ships endpoint groups designed to exercise specific API-gateway behaviors. Each is a thin, deterministic shim. + +- [Endpoints overview](https://api-test.plexara.io/endpoints/overview/): Catalog and the determinism contract. +- [Identity](https://api-test.plexara.io/endpoints/identity/): `/v1/whoami`, `/v1/headers`. Verify the gateway forwards identity and headers (with redaction). +- [Data](https://api-test.plexara.io/endpoints/data/): `/v1/fixed/{key}`, `/v1/sized?bytes=N`, `/v1/lorem?words=N&seed=S`. Deterministic outputs for testing dedup, response-size handling, and caching. +- [Failure modes](https://api-test.plexara.io/endpoints/failure/): `/v1/status/{code}`, `/v1/slow?ms=N`, `/v1/flaky?fail_rate=&seed=&call_id=`. Controlled error categories, latency injection, seeded flake rates. +- [Echo](https://api-test.plexara.io/endpoints/echo/): `ANY /v1/echo`. Generic catch-all that returns the request verbatim, with auth headers redacted. + +## Operations + +- [Audit log](https://api-test.plexara.io/operations/audit/): Postgres schema, retention, redaction, and the JSON shape returned by the portal API. +- [Deployment](https://api-test.plexara.io/operations/deployment/): Docker, Kubernetes, distroless image, healthcheck, graceful shutdown. +- [Testing a gateway](https://api-test.plexara.io/operations/gateway-testing/): Patterns for asserting on the Plexara API gateway's auth-forwarding, redaction, pagination detection, error surfacing, and timeout enforcement using api-test as the upstream. + +## Reference + +- [HTTP API](https://api-test.plexara.io/reference/http-api/): Health, well-known metadata, and the portal/admin API surface. +- [Architecture](https://api-test.plexara.io/reference/architecture/): Component diagram, request flow, audit pipeline, embed model. +- [Releases](https://api-test.plexara.io/reference/releases/): Version policy, container image tags, migration notes. + +## Optional + +- [Source on GitHub](https://github.com/plexara/api-test): Apache 2.0; issues and PRs welcome. +- [Container image](https://github.com/plexara/api-test/pkgs/container/api-test): GHCR multi-arch images, signed via cosign on tag. +- [Sister project: mcp-test](https://mcp-test.plexara.io/): Same role as api-test, but for MCP gateways instead of HTTP API gateways. +- [Plexara](https://plexara.io): The commercial unified MCP+API gateway. api-test is what we use to verify Plexara's API-gateway behavior end-to-end; we ship it as OSS so anyone building API gateways can use the same fixture. diff --git a/docs/operations/audit.md b/docs/operations/audit.md new file mode 100644 index 0000000..80c4b04 --- /dev/null +++ b/docs/operations/audit.md @@ -0,0 +1,163 @@ +--- +title: Audit log +description: Postgres schema, retention, redaction, and the JSON shape returned by the portal API for api-test's HTTP audit log. +--- + +# Audit log + +Every request that reaches an `/v1/*` endpoint produces one row in +`audit_events` and (when `audit.capture_payloads` is true, the +default) one row in `audit_payloads`. Health, readiness, well-known, +and portal-auth flows sit outside the middleware stack and don't +generate audit rows. + +The pipeline is async: the request handler enqueues into a buffered +channel; a background goroutine drains into Postgres. A stalled DB +can never inflate request latency. On a full buffer the event is +*dropped* and counted (logged every 1000th drop). For lossless audit, +size the buffer for your peak rate. + +## Schema + +Two tables, one row each, joined 1:1 on `audit_events.id = +audit_payloads.event_id`. + +### `audit_events` + +The indexable summary. One row per request. + +| Column | Type | Notes | +| --- | --- | --- | +| `id` | TEXT (UUID) | Primary key. Auto-generated when the inserter omits it. | +| `ts` | TIMESTAMPTZ | Request start, UTC. Indexed (`DESC`). | +| `duration_ms` | BIGINT | Total handler time including audit middleware overhead. | +| `request_id` | TEXT | `X-Request-Id` (preserved or generated). | +| `session_id` | TEXT | Reserved for the portal Try-It flow. | +| `user_subject` | TEXT | Resolved Identity.Subject. Indexed. | +| `user_email` | TEXT | OIDC only. | +| `auth_type` | TEXT | `anonymous` / `apikey` / `bearer` / `oauth2`. | +| `api_key_name` | TEXT | KeyName for apikey/bearer; client_id for OIDC. | +| `method` | TEXT | HTTP method. | +| `path` | TEXT | Path the request landed on. Indexed. | +| `route_name` | TEXT | The matched route's name (group-scoped). | +| `endpoint_group` | TEXT | The owning group: `identity`, `data`, etc. | +| `status` | INTEGER | Response status. Indexed. | +| `bytes_in` | INTEGER | Inbound body size (raw). | +| `bytes_out` | INTEGER | Outbound body size (full, including any portion truncated from capture). | +| `success` | BOOLEAN | `200 <= status < 400`. | +| `error_message` | TEXT | Reserved for handlers that surface errors. | +| `error_category` | TEXT | Operator-defined free text. | +| `remote_addr` | TEXT | `r.RemoteAddr`. | +| `user_agent` | TEXT | `User-Agent` header. | + +Indexes: `(ts DESC)`, `(route_name, ts DESC)`, `(path, ts DESC)`, +`(user_subject, ts DESC)`, `(session_id, ts DESC)`, +`(status, ts DESC)`. + +### `audit_payloads` + +The detail row. Same shape as `Event.Payload` in Go. + +| Column | Type | Notes | +| --- | --- | --- | +| `event_id` | TEXT | PK; FK to `audit_events.id` ON DELETE CASCADE. | +| `request_headers` | JSONB | Map[name]=values, with redaction applied. | +| `request_query` | JSONB | Map[name]=values, with redaction applied. | +| `request_content_type` | TEXT | Inbound `Content-Type`. | +| `request_body` | BYTEA | Up to `audit.max_payload_bytes`; longer bodies write the prefix and set `request_truncated`. | +| `request_size_bytes` | INTEGER | Captured prefix size (not the full inbound size). | +| `request_truncated` | BOOLEAN | True when the inbound body exceeded the cap. | +| `request_remote_addr` | TEXT | Mirror of summary; convenient for joins. | +| `response_headers` | JSONB | Same redaction. | +| `response_content_type` | TEXT | Outbound `Content-Type`. | +| `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+. | +| `captured_at` | TIMESTAMPTZ | Insert time of the payload row. | + +Indexes: `(replayed_from)` partial, GIN on `request_headers` and +`response_headers` (jsonb_path_ops, used for portal filter queries). + +## Redaction + +The middleware runs every header name and query-param key through +`audit.SanitizeHeaders` / `audit.SanitizeQuery` before insert. +`audit.redact_keys` is a list of case-insensitive substrings; matches +have their values replaced by `[redacted]`. + +Default list: + +``` +password, token, secret, authorization, api_key, api-key, +credentials, bearer, cookie, jwt, session_id, private_key, passwd +``` + +Both `api_key` and `api-key` are present so both `Authorization` / +`X-API-Key` headers (dashes) and `?api_key=...` query params +(underscore in the param name convention) get caught. + +Bodies are *not* redacted. If a request body carries credentials in a +JSON field, store-and-forget it at your own risk; consider lowering +`audit.max_payload_bytes` or disabling `audit.capture_payloads` +entirely in privacy-sensitive deployments. + +## Retention + +```yaml +audit: + retention_days: 7 +``` + +Default is 7 days (lower than mcp-test's 30 because api-test responses +can be huge — export endpoints emit 100 MiB bodies). A periodic +cleanup job lands in a future migration; for now, prune manually: + +```sql +DELETE FROM audit_events WHERE ts < now() - interval '7 days'; +-- audit_payloads cascades. +``` + +## Backpressure + +`AsyncLogger` wraps the inner store with a buffered channel (default +4096 events) and a per-call timeout (default 5s). On a full buffer the +event is dropped and the cumulative drop count is logged every 1000th +drop: + +``` +WARN audit buffer full; dropping events dropped_total=1 +WARN audit buffer full; dropping events dropped_total=1001 +``` + +If you see drops in steady-state load, raise `bufferSize` in +`internal/server.Build` or scale the database. + +## Querying + +The Go side exposes `audit.QueryFilter` with these predicates: time +range, method, path, route_name, user_subject, session_id, event_id, +status, success, search (ILIKE on path + error_message), limit, +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 +returns paginated JSON. Direct SQL is also fine; the schema is +documented above and Postgres is the source of truth. + +## What it proves about the gateway + +The audit log is the assertion surface for "did the gateway forward +what we expected". Common assertions: + +- The gateway forwarded the credential under the right transport + (`auth_type` matches what the connection promised). +- The gateway did *not* forward a header you blocked + (`request_headers` doesn't carry it). +- The gateway forwarded a custom tracing header + (`request_headers["X-Trace-Id"]` is present). +- The gateway returned the upstream status verbatim (compare + `status` here vs the gateway-side record). +- The gateway retried (multiple audit rows for one client call, + visible by request_id correlation when the gateway preserves it). diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md new file mode 100644 index 0000000..19197e1 --- /dev/null +++ b/docs/operations/deployment.md @@ -0,0 +1,135 @@ +--- +title: Deployment +description: Docker, Kubernetes, distroless image, healthcheck, graceful shutdown. +--- + +# Deployment + +api-test is a single static Go binary plus a Postgres dependency. The +operational surface is small on purpose; treat it like any standard +HTTP service. + +## Container image + +`ghcr.io/plexara/api-test:latest` is a `gcr.io/distroless/static-debian12:nonroot` +base with the binary at `/usr/local/bin/api-test`. The default +entrypoint runs the binary against `/etc/api-test/api-test.yaml`; mount +your config there. + +Multi-arch tags: linux/amd64, linux/arm64. Image is signed via cosign +on tag. + +```bash +docker run --rm -p 8080:8080 \ + -v $(pwd)/configs/api-test.live.yaml:/etc/api-test/api-test.yaml:ro \ + -e APITEST_DEV_KEY=... \ + -e APITEST_DB_URL=postgres://api:api@postgres:5432/apitest?sslmode=disable \ + ghcr.io/plexara/api-test:vX.Y.Z +``` + +## Healthcheck + +The binary doubles as its own healthcheck so the distroless image +doesn't need curl/wget. + +```bash +api-test --healthcheck +echo $? # 0 on 200 from /healthz, non-zero otherwise +``` + +The Dockerfile wires this in: + +```dockerfile +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD ["/usr/local/bin/api-test", "--healthcheck"] +``` + +Override the probe URL via `APITEST_HEALTHCHECK_URL` when the binary +listens on a non-default port. + +## Graceful shutdown + +On SIGINT or SIGTERM: + +1. Flip `/readyz` to 503 (load balancer should drain). +2. Sleep `server.shutdown.pre_shutdown_delay` (default 2s) so LB + notices. +3. Call `http.Server.Shutdown` with a `server.shutdown.grace_period` + timeout (default 25s); in-flight requests get to finish. +4. Close the audit `AsyncLogger` (drains the buffer to Postgres). +5. Close the database pool. + +A second SIGINT short-circuits the pre-shutdown delay so an impatient +operator can force-quit. + +## Liveness vs readiness + +| Probe | What it checks | Status | +| --- | --- | --- | +| `/healthz` | Process is alive. | 200 always. | +| `/readyz` | Server is accepting traffic. | 200 normally; 503 during shutdown drain. | + +For Kubernetes: + +- **Liveness probe**: `/healthz`. Restart on failure. +- **Readiness probe**: `/readyz`. Pull from service endpoints on + failure. +- **Startup probe**: `/healthz`, with a generous `failureThreshold`, + so migrations have time to run on first boot. + +## Kubernetes example + +A self-contained example manifest set lives at +[`examples/kubernetes/`](https://github.com/plexara/api-test/tree/main/examples/kubernetes) +(landing in M5). It deploys api-test plus Postgres, configures an +nginx ingress with cert-manager, and seeds a single API key from a +Secret. + +```bash +kubectl apply -f examples/kubernetes/ +kubectl -n api-test get pods +``` + +For production deployments, replace the embedded Postgres with a +managed instance (RDS, Cloud SQL, Crunchy Bridge) and pin a stable +container image tag. + +## Resource sizing + +api-test has a small, predictable footprint: + +- ~30 MiB RSS at idle. +- ~60–80 MiB RSS under sustained 1 krps load with payload capture on. +- ~1 ms middleware overhead per request (RequestID + AccessLog + + Identity + Audit) on a 2024-class CPU. Audit DB write is async; the + request path doesn't wait for it. + +Sized appropriately for a 0.1–0.5 vCPU / 128–256 MiB request, with +limits at 1 vCPU / 512 MiB to absorb burst. + +## Logging + +Structured JSON via slog, written to stderr. Override the level via +`LOG_LEVEL=debug|info|warn|error`. Every line carries: + +- `time` (RFC 3339 nano). +- `level`, `msg`. +- `method`, `path`, `status`, `bytes`, `duration_ms` for request lines. +- `request_id` for traceability (generated or preserved from `X-Request-Id`). +- `auth_type`, `subject` when the identity middleware ran. + +## Metrics + +Prometheus metrics endpoint lands in M5. Until then, derive metrics +from the structured access log or query the audit table: + +```sql +-- p50/p95 latency, last hour, by endpoint group +SELECT endpoint_group, + percentile_cont(0.5) WITHIN GROUP (ORDER BY duration_ms) AS p50, + percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms) AS p95, + count(*) +FROM audit_events +WHERE ts > now() - interval '1 hour' +GROUP BY endpoint_group; +``` diff --git a/docs/operations/gateway-testing.md b/docs/operations/gateway-testing.md new file mode 100644 index 0000000..84e7bbe --- /dev/null +++ b/docs/operations/gateway-testing.md @@ -0,0 +1,199 @@ +--- +title: Testing a gateway +description: Patterns for asserting on the Plexara API gateway's auth-forwarding, redaction, pagination detection, error surfacing, and timeout enforcement using api-test as the upstream. +--- + +# Testing a gateway + +api-test is built for the loop: change one thing on the gateway, hit +api-test through it, observe the diff. This page documents the +patterns each endpoint group makes possible. + +The examples assume Plexara is reachable at +`https://plexara.example.com`, api-test is registered as a connection +named `api-test`, and the operator calls Plexara via an MCP client +(Claude Code, the SDK, raw JSON-RPC). + +## Identity forwarding + +**Question**: did the gateway forward the right credential under the +right transport? + +```text +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/whoami") + → { "subject": "...", "auth_type": "...", "key_name": "..." } +``` + +**Assertion**: + +- `auth_type` matches the connection's registered `auth_mode` + (`apikey`, `bearer`, `oauth2`). +- `subject` is the credential name api-test recognizes (the + `name` field of the matching `api_keys.file` / `bearer.tokens` / + the OIDC `sub` claim). + +**Falsifies**: + +- Mis-registered connection (auth_mode doesn't match what the gateway + is actually sending). +- Stale credential (gateway is still using a rotated value). +- Auth-mode confusion (a misconfigured `oauth2` flow that falls back + to a stale static token). + +## Header pass-through + +**Question**: which headers did the gateway forward, and which did it +strip or rewrite? + +```text +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/headers", + headers={"X-Trace-Id": "abc", "X-Custom": "vendor"}) +``` + +**Assertion** — open the api-test audit row: + +- `request_headers["X-Trace-Id"] = ["abc"]` if the gateway forwards. +- `request_headers["X-Custom"] = ["vendor"]` if the gateway forwards. +- `request_headers["Authorization"]` is **not present** in the row + (the gateway should set `Authorization` from the connection's + registered credential, not pass through caller-supplied headers + that override it). + +**Falsifies**: + +- Header injection (gateway lets caller-supplied `Authorization` + through and api-test sees it instead of the registered credential). +- Header stripping (gateway drops a custom header you expected to + forward). + +## Status code surfacing + +**Question**: does the gateway pass through upstream HTTP statuses +verbatim, or collapse them into tool-level errors? + +```text +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/status/503") +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/status/418") +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/status/401") +``` + +**Assertion**: + +- The gateway response envelope's `status` field carries the upstream + status (503, 418, 401). +- The gateway does *not* mark the call as a tool-level error for + upstream 4xx/5xx; that's an upstream HTTP response, not a transport + failure. + +**Falsifies**: + +- Gateway collapses non-2xx to a tool error (loses information). +- Gateway maps 401 from upstream into "auth failed" at the gateway + layer (misleading; the gateway's own auth is still fine). + +## Timeout enforcement + +**Question**: does the gateway respect its own `connect_timeout`, +`call_timeout`, and per-call `timeout_seconds`? + +```text +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/slow?ms=8000", + timeout_seconds=2) +``` + +**Assertion**: + +- The gateway returns `error: "timeout"` (or equivalent) within ~2s. +- api-test's audit row shows status 499 with `cancelled: true` (the + gateway propagated the cancellation). + +**Falsifies**: + +- Gateway ignores `timeout_seconds` (waits the full 8s). +- Gateway times out but doesn't propagate cancellation (api-test + finishes its 8s sleep and writes a 200 row anyway). + +## Retry policy + +**Question**: when does the gateway retry, and how many times? + +```text +api_invoke_endpoint(connection="api-test", method="GET", + path="/v1/flaky?fail_rate=1&seed=demo&call_id=1") +``` + +**Assertion** — count audit rows for `path = "/v1/flaky"`: + +- One row → no retry (gateway surfaces the failure immediately). +- N rows → gateway retried N-1 times. + +**Falsifies**: + +- Gateway retries on 503 when policy says it shouldn't. +- Gateway gives up after the wrong number of attempts. + +## Redaction + +**Question**: did the gateway log the credential anywhere? + +Open the api-test audit drawer for a recent call. Confirm: + +- `request_headers["Authorization"]` (or the gateway's chosen header) + shows `[redacted]`, not the real value. +- `response_body` doesn't accidentally echo the credential back from + somewhere upstream. + +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+) + +**Question**: did the gateway recognize the upstream's pagination +cursor? + +api-test exposes one endpoint per cursor style: + +- `/v1/page/link` — RFC 5988 `Link: <…>; rel="next"`. +- `/v1/page/odata` — body field `@odata.nextLink`. +- `/v1/page/cursor` — body field `next_cursor`. +- `/v1/page/cursor-camel` — `nextCursor`. +- `/v1/page/google` — `next_page_token`. +- `/v1/page/google-camel` — `nextPageToken`. +- `/v1/page/generic` — `next`. +- `/v1/page/none` — single page, no cursor (negative test). +- `/v1/page/mixed` — both Link header AND body cursor (precedence + test; Link should win). + +**Assertion**: gateway response envelope's `pagination` field: + +- For each style, the cursor value should match what api-test put on + the wire. +- For `/v1/page/none`, `pagination` should be absent or null. +- For `/v1/page/mixed`, the gateway should prefer the Link header. + +## Snapshot fixtures + +For deterministic regression suites, use the `data` group: + +```text +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/fixed/regression-key-42") +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/lorem?words=20&seed=test") +api_invoke_endpoint(connection="api-test", method="GET", path="/v1/sized?bytes=1024") +``` + +These bodies never change. Snapshot once; assert byte-equal forever. + +## What lives where + +| Concern | Where to look | +| --- | --- | +| What the client called Plexara with | Plexara's MCP audit log. | +| What Plexara forwarded to api-test | api-test's `audit_events` + `audit_payloads` rows. | +| What api-test sent back | Same payload row, response side. | +| What the client got back from Plexara | Plexara's MCP audit log, response section. | + +The *diff* between "what the client sent" and "what api-test received" +is the gateway's contribution to the call. The diff between "what +api-test sent back" and "what the client got" is the gateway's +contribution to the response. Both directions are observable. diff --git a/docs/operations/portal.md b/docs/operations/portal.md new file mode 100644 index 0000000..a8bee5f --- /dev/null +++ b/docs/operations/portal.md @@ -0,0 +1,80 @@ +--- +title: Portal +description: The embedded React SPA for browsing the audit log, calling endpoints from a Try-It form, managing API keys, and viewing the OpenAPI document. +--- + +# Portal + +The portal is a React 19 + Vite + Tailwind 4 SPA, compiled into the +binary via `go:embed`. It mounts at `/portal/` when `portal.enabled` is +true; the portal API lives at `/api/v1/portal/*` and is gated by an +operator session cookie established via OIDC PKCE. + +!!! note "Lands in M3" + The portal binary support is in place (the Go side mounts + `internal/ui/dist` if it has an `index.html`); the SPA itself + arrives in M3. Until then, point `portal.enabled` to `false` and + use the curl examples in [Quickstart](../getting-started/quickstart.md) + or hit Postgres directly for audit queries. + +## Pages + +| Page | Purpose | +| --- | --- | +| **Dashboard** | Requests/min, p50/p95 latency, status-code histogram, top routes by error rate. | +| **Endpoints** | Catalog of every registered route (method/path/group/auth filter) with a Try-It panel that POSTs through the portal API to the local mux. | +| **Audit** | Filterable, paginated event view; click a row for the full request/response drawer with redaction overlays. | +| **API Keys** | Create / revoke Postgres-backed bcrypt keys. Plaintext shown once. | +| **Config** | Read-only YAML of the running server, with secrets masked. | +| **Discovery** | Redoc/Swagger UI iframe over `/openapi.json`; click-to-copy connection-registration YAML for the Plexara admin API. | +| **About** | Build info + "test against Plexara" cheat sheet. | + +## Authentication + +Two paths reach portal data: + +- **Browser session** — operator hits `/portal/`, gets redirected to + the IdP, completes OIDC PKCE, returns with a session cookie. + Subsequent portal API calls use the cookie. +- **API key** — paste an `X-API-Key` value into the portal login screen + for headless operators (CI dashboards, kiosks). The portal API + accepts both schemes. + +The portal session is *separate* from the inbound auth chain that +gates `/v1/*`. An operator can have a portal session without any of +the gateway's connection credentials. + +## Try-It + +Click any endpoint in the catalog to open a per-route Try-It panel: + +- Form fields generated from the route's input schema (path params, + query params, headers, body). +- The form POSTs to a `/api/v1/portal/tryit/{group}/{route}` endpoint + that proxies the call into the local mux. The result lands in the + portal's audit feed exactly like an external call would, tagged + `source: portal-tryit`. +- Errors and redactions in the response render the same way they would + for a Plexara-forwarded call, so what the operator sees in the portal + matches what the gateway sees over the wire. + +## Inspection drawer + +Clicking an audit row opens a side panel: + +- **Overview** — identity, status, route, durations. +- **Request** — full URL with query, headers, body. Sensitive headers + show with an "[redacted]" overlay; clicking the overlay reveals the + stored value (which is `[redacted]` literally — the secret was never + stored). +- **Response** — same shape for the outbound side. +- **Replay** — re-issue the same request through the portal API, + preserving the original event id in the new row's `replayed_from` + column for traceability. + +## SSE live tail + +The Audit page subscribes to a server-sent-events stream of newly +written audit events, surfaced as a fixed-cap buffer above the +historical filter view. The historical table stays still so the live +read doesn't blow away your filtered context. diff --git a/docs/overrides/home.html b/docs/overrides/home.html new file mode 100644 index 0000000..58d5437 --- /dev/null +++ b/docs/overrides/home.html @@ -0,0 +1,196 @@ +{# + Home page template. Replaces Material's article surface with a Plexara- + flavored composition: hero (woven-pattern + hero-glow + LogoMark), + feature grid (cards), reasons rail, and a CTA strip. + + Triggered via `template: home.html` in the index.md front matter. We + still extend `main.html` so the global header, footer, search, and + announce bar are unchanged. The Markdown content of index.md is + available as `page.content` and rendered below the hero so existing + prose stays editable in source. + + Note: the portal-screenshots carousel that mcp-test ships in this + template is omitted here. The api-test portal lands in M3; once + screenshots exist under docs/images/portal/, copy the .plex-shots + section back from mcp-test verbatim. +#} +{% extends "main.html" %} + +{# Tag the body so extra.css can collapse Material's content padding. #} +{% block site_meta %} + {{ super() }} +{% endblock %} + +{% block content %} +
+ + + + + + +
+ +
+
+ What's inside +

Capabilities

+

+ The endpoints are intentionally boring. They return predictable output + for predictable input, fail in well-defined ways when asked to, and + log every request. That's enough to write end-to-end assertions about + a gateway's behavior without depending on real upstream data. +

+ +
+
+ +

Endpoint groups by behavior

+

+ Identity (whoami, headers), data (deterministic fixtures: fixed, + sized, lorem), failure modes (status, slow, flaky), and a generic + echo. More groups (pagination styles, methods, security probes, + export targets) land in upcoming releases. +

+
+ +
+ +

Real inbound auth, four ways

+

+ File-loaded API keys (header or query placement, constant-time + compare), bcrypt-hashed Postgres-backed keys, static bearer tokens, + and OIDC JWT validation against an external IdP. Matches every + auth mode the Plexara API gateway forwards. +

+
+ +
+ +

Audit log of every request

+

+ Postgres-backed timeline with sanitized headers, query params, + request and response bodies, identity, latency, and status. Browse, + filter, and chart it in the embedded React portal. +

+
+ +
+ +

Self-describing OpenAPI

+

+ Every route is published in an OpenAPI 3.x document at + /openapi.json, generated in-tree from the same + metadata the portal uses, so the Plexara gateway's + api_list_endpoints tool sees an exact contract. +

+
+ +
+ +

Pagination, the gateway recognizes

+

+ One endpoint per cursor style the gateway's pagination detector + recognizes: RFC 5988 Link headers, OData + @odata.nextLink, and the common cursor field + variants. Negative tests included so detection failures are + falsifiable. +

+
+ +
+ +

By Plexara

+

+ Plexara + is a unified MCP + API gateway with configurable enrichment built + in. api-test is what we use to verify Plexara's API-gateway + behavior end-to-end; we ship it as OSS so anyone building API + gateways can use the same fixture. +

+
+
+
+
+ +
+
+ Why this exists +

Why a separate test fixture

+

+ Pair api-test with the audit log and you can write end-to-end + assertions about gateway behavior without running fragile real-data + fixtures. +

+ +
    +
  • + Endpoints that return the same body for the same input + (/v1/fixed/{key}, /v1/lorem?seed=…). +
  • +
  • + Endpoints that fail on demand with any HTTP status + (/v1/status/{code}) and on a schedule + (/v1/slow?ms=N, /v1/flaky?fail_rate=&seed=). +
  • +
  • + Endpoints that emit every pagination shape the gateway recognizes + so detection logic is falsifiable. +
  • +
  • + Endpoints that echo identity and headers so you can confirm what's + being forwarded (/v1/whoami, /v1/headers). +
  • +
+
+
+{% endblock %} + +{% block content_actions %}{# hide the article actions on the home page #}{% endblock %} diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 0000000..39b7ce6 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,177 @@ +{% extends "base.html" %} + +{# + Site-wide overrides for api-test docs (Plexara identity). + - extrahead: preconnect, custom OG / theme color metadata. + - announce: dismissible banner used for release pointers. + - footer: full Plexara-style footer replaces Material's two-row default. +#} + +{% block extrahead %} + {{ super() }} + + {# Theme + favicon plumbing. The mark itself lives at images/logo.svg + and is referenced from mkdocs.yml; we set the OS / browser surfaces + so PWA-style installs and tab pinning use the Plexara midnight. #} + + + + + + + {# Canonical URL. Google strongly prefers a single canonical per page; + mkdocs-material exposes page.canonical_url which combines site_url + with the page path. #} + {% if page and page.canonical_url %} + + {% endif %} + + {# OpenGraph (Facebook/LinkedIn/Slack/Discord cards). #} + + + + {% if page %} + + + {% if page.canonical_url %}{% endif %} + {% else %} + + + {% endif %} + {# OG image: a hand-designed 1200x630 card at images/og-card.png used + site-wide so every share preview shows the same Plexara-branded + card regardless of which page the link points at (industry pattern + for OSS docs: Tailwind, Stripe, Vercel all do this). mkdocs-material's + auto-generated per-page social cards (via page.meta.image) take + priority when present, but we keep the plugin disabled by default in + favor of the curated card; flip enabled: !ENV [CI, true] in mkdocs.yml + to opt into per-page cards. #} + {% if page and page.meta and page.meta.image %} + + + + {% else %} + + + + + + + + + {% endif %} + + {# Twitter / X cards. #} + + + + + {# Standard SEO. #} + + + + + {# JSON-LD: SoftwareSourceCode + Organization. Helps Google's knowledge + graph attach the site to the project and the publisher. #} + + + {# Google Analytics 4. Same property as the Plexara marketing site so + api-test traffic rolls up under one account. consent default + "denied" matches the marketing site's CMP-friendly posture. #} + + +{% endblock %} + +{# No announce/banner block. Material renders nothing if {% block announce %} + is empty, but to be safe we explicitly clear it. #} +{% block announce %}{% endblock %} + +{# Replace Material's stock footer with the Plexara multi-column footer. #} +{% block footer %} + +{% endblock %} diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md new file mode 100644 index 0000000..ddee835 --- /dev/null +++ b/docs/reference/architecture.md @@ -0,0 +1,200 @@ +--- +title: Architecture +description: Component diagram, request flow, audit pipeline, embed model. +--- + +# Architecture + +api-test is a small, intentionally legible Go service. The +composition root (`internal/server.Build`) wires four things together: +config, database (optional), auth chain, endpoint registry; the result +is an `http.Handler` that's served from `cmd/api-test/main.go`. + +## Component diagram + +```mermaid +flowchart TB + subgraph Binary["api-test binary"] + direction TB + Mux["http.ServeMux
+ CORS"] + subgraph MW["Middleware stack"] + direction TB + ReqID["RequestID"] + AccessLog["AccessLog (slog)"] + Identity["Identity (auth chain)"] + Audit["Audit (Event/Payload)"] + end + subgraph Groups["pkg/endpoints"] + direction LR + Identity_g["identity"] + Data_g["data"] + Failure_g["failure"] + Echo_g["echo"] + end + Health["healthz / readyz"] + Portal["/portal/ (M3+)"] + OpenAPI["/openapi.json (M4+)"] + SPA["embedded SPA
(go:embed all:dist)"] + end + + subgraph DB["Postgres"] + api_keys + audit_events + audit_payloads + end + + subgraph IdP["Keycloak (dev)"] + JWKS + end + + Plexara["Plexara API gateway"] -->|HTTP + auth| Mux + Mux --> MW + MW --> Groups + MW -.->|inbound auth lookup| api_keys + MW -->|async drain| audit_events + MW -->|async drain| audit_payloads + MW -.->|JWT validation| JWKS + Mux --> Health + Mux --> Portal + Mux --> OpenAPI + Portal --> SPA +``` + +## Request flow + +For an inbound `/v1/*` request, outermost first: + +1. **RequestID**: preserves inbound `X-Request-Id` or generates a new + UUID; echoes on the response; stashes in context; seeds the + per-request `identityHolder` so AccessLog can later read what + Identity wrote. +2. **AccessLog**: records start time. Wraps the response writer to + capture status + bytes; emits one `slog.Info` line on completion + with method, path, status, duration, request_id, and (when the + per-route Identity middleware ran) auth_type + subject. +3. **CORS**: sets `Access-Control-*` headers; preflight (OPTIONS) + short-circuits with 204. +4. **mux dispatch**: Go 1.22+ method+path pattern matcher selects the + per-route chain (or one of the bare health/well-known/root + handlers, which skip the per-route middleware below). +5. **Identity** (per-route): runs the inbound auth chain. On match, + attaches the `inbound.Identity` to the context, mirrors it into + the holder seeded by RequestID, and calls the next handler. On + mismatch, responds 401 with `WWW-Authenticate`. +6. **Audit** (per-route): starts an event timer; tees the request body + into a capped buffer; wraps the response writer to capture body + and status. After the handler returns, builds an `audit.Event` + (and optional `Payload`) and calls `auditLog.Log` (non-blocking — + the `AsyncLogger` enqueues into a buffered channel and returns). +7. **Endpoint handler**: reads the request body, builds the response. + For `whoami`, reads the identity off context. + +Health (`/healthz`, `/readyz`) and well-known endpoints sit *outside* +the Identity + Audit middleware so they don't generate audit rows or +require credentials. + +## Auth chain + +`pkg/auth/inbound`: + +```mermaid +flowchart LR + Req["HTTP request"] --> APIKey["APIKeyAuthenticator
(header → query)"] + APIKey -->|match| Identity["Identity attached"] + APIKey -->|no credential| Bearer["BearerAuthenticator"] + Bearer -->|match| Identity + Bearer -->|no credential| OIDC["OIDCAuthenticator (M3+)"] + OIDC -->|match| Identity + OIDC -->|no credential & allow_anonymous| Anon["Anonymous Identity"] + OIDC -->|no credential & not anonymous| Reject["401"] + + APIKey -->|invalid| Reject + Bearer -->|invalid| Reject + OIDC -->|invalid| Reject +``` + +A bad credential **stops the chain immediately** (no fall-through). +Only *absent* credentials advance. This prevents accidental cross-mode +matches. + +## Audit pipeline + +```mermaid +flowchart LR + Handler["Request handler"] -->|Event{}| Async["AsyncLogger
(buffered channel)"] + Async -->|drain goroutine| PG["Postgres store"] + PG --> EvT[(audit_events)] + PG --> PlT[(audit_payloads)] + Async -.->|on full buffer| Drop["drop + counter++"] +``` + +- `AsyncLogger.Log` is non-blocking. The handler's request path never + waits on the database. +- The drain goroutine writes one event at a time with a per-call + timeout (`5s` default). +- Summary and detail rows commit in the same transaction so they're + atomically visible. +- A full buffer drops events; the drop counter is logged at WARN + every 1000th drop. + +## Embed model + +``` +internal/ui/embed.go + //go:embed all:dist + var distFS embed.FS +``` + +The portal SPA is compiled into the binary at build time. `make ui` +runs `pnpm install && pnpm build` in `ui/`, then copies `ui/dist/` +into `internal/ui/dist/`. `go build` picks up the embed automatically. + +When `internal/ui/dist/` only contains `.gitkeep` (no SPA built), the +mux falls back to a small JSON banner at `/portal/` so the binary still +runs without the Node toolchain. + +## Lifecycle + +```mermaid +sequenceDiagram + participant Main + participant Build + participant Run + participant OS + Main->>Build: server.Build(ctx, cfg, logger) + Build->>Build: migrate.Up(db.URL) + Build->>Build: pgxpool.New + Build->>Build: NewAsyncLogger + Build->>Build: NewChain(file, db, bearer, oidc) + Build->>Build: buildRegistry + Mount + Build-->>Main: *Application + Main->>Run: app.Run(ctx) + Run->>OS: ListenAndServe + OS-->>Run: SIGTERM + Run->>Run: readiness.SetReady(false) + Run->>Run: sleep pre_shutdown_delay + Run->>OS: srv.Shutdown(grace_period) + Main->>Build: app.Close() + Build->>Build: asyncAudit.Close (drain) + Build->>Build: pool.Close +``` + +## Reusing pieces + +The packaging is opinionated but the pieces are independent: + +- `pkg/config` — YAML loader with `${VAR:-default}` interpolation. No + api-test deps. +- `pkg/database` + `pkg/database/migrate` — pgxpool wrapper + + golang-migrate runner. Generic. +- `pkg/audit` — Logger interface, in-memory + Postgres impls, + AsyncLogger wrapper. The Event/Payload shape is HTTP-flavored but + the surface is generic. +- `pkg/auth/inbound` — Authenticator interface + chain composer. +- `pkg/httpmw` — RequestID, Identity, AccessLog, Audit. Drop-in for + any Go service. +- `pkg/endpoints` — registry pattern. The `Endpoints` interface (Name, + Routes, Mount) is a useful organizational tool for any service + with many routes grouped by behavior. + +Lift any of these into your own service with the import path rewritten. diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md new file mode 100644 index 0000000..5eaffde --- /dev/null +++ b/docs/reference/http-api.md @@ -0,0 +1,132 @@ +--- +title: HTTP API +description: Health probes, well-known metadata, and the portal/admin API surface. +--- + +# HTTP API + +This page documents the non-`/v1/*` HTTP surface: health probes, +well-known metadata, and the portal + admin API. The `/v1/*` endpoint +groups have their own pages under [Endpoints](../endpoints/overview.md). + +## Health and readiness + +```http +GET /healthz → 200 "ok" +GET /readyz → 200 "ready" (normally) + → 503 "draining" (during shutdown grace) +``` + +Both return plain text. Useful for K8s probes, load-balancer health +checks, and the binary's own `--healthcheck` flag. + +## OpenAPI document (M4+) + +```http +GET /openapi.json +GET /openapi.yaml +``` + +The full OpenAPI 3.x document for every registered endpoint group. +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+) + +```http +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+) + +```http +GET /.well-known/oauth-protected-resource +GET /.well-known/openid-configuration (when oidc.enabled) +``` + +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+) + +Read-only endpoints under `/api/v1/portal/`. All require an authenticated +operator session (browser cookie or `X-API-Key`). + +| Method + path | Returns | +| --- | --- | +| `GET /api/v1/portal/me` | Current operator identity. | +| `GET /api/v1/portal/endpoints` | All registered routes with metadata. | +| `GET /api/v1/portal/audit/events` | Filtered audit events; pagination via `?limit=&offset=`. | +| `GET /api/v1/portal/audit/events/{id}` | Single event with payload row joined. | +| `GET /api/v1/portal/audit/timeseries` | Bucketed `count`, `errors`, `avg_duration_ms` for the dashboard. | +| `GET /api/v1/portal/audit/breakdown` | Group-by `tool`, `user`, `success`, `auth_type`. | +| `GET /api/v1/portal/audit/stats` | p50/p95 latency, error rate, totals. | +| `GET /api/v1/portal/audit/stream` | SSE feed of newly written events. | +| `GET /api/v1/portal/audit/export.ndjson` | Filtered events as NDJSON for bulk export. | +| `POST /api/v1/portal/audit/replay/{id}` | Re-issue a captured request through the local mux. | +| `POST /api/v1/portal/tryit/{group}/{route}` | Try-It dispatch into the local mux. | +| `GET /api/v1/portal/config` | Loaded config with secrets masked. | + +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+) + +Mutation endpoints under `/api/v1/admin/`. Same auth requirements as +the portal API; intended for portal use plus operator scripts. + +| Method + path | Effect | +| --- | --- | +| `GET /api/v1/admin/api-keys` | List bcrypt-stored API keys (no plaintext). | +| `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`) + +| Method + path | Effect | +| --- | --- | +| `GET /portal/auth/login` | Begin OIDC PKCE; 302 to the IdP. | +| `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+) + +```http +GET /portal/ → SPA index.html +GET /portal/{any-other} → SPA index.html (client-side router) +GET /portal/assets/{file} → SPA static asset +``` + +The mux strips the `/portal` prefix and serves from the embedded +`internal/ui/dist` filesystem. SPA routes that aren't real files fall +back to `index.html` so the React Router handles them. + +## CORS + +The mux wraps every response in permissive CORS headers +(`Access-Control-Allow-Origin: *`). api-test is a test fixture; CORS +is the path of least resistance for browser-based test runners. +Production deployments behind a real reverse proxy can override. + +## Mux ordering + +Go 1.22+ `http.ServeMux` patterns: + +1. `GET /healthz` — exact. +2. `GET /readyz` — exact. +3. `GET /.well-known/...` — exact. +4. `GET /openapi.{json,yaml}`, `GET /docs` — exact. +5. `/api/v1/portal/*`, `/api/v1/admin/*` — by prefix. +6. `/portal/...` — by prefix; serves SPA. +7. ` /v1/...` — exact per (method, path), one per registered + route. +8. `GET /` — root banner / 404 JSON fallback. + +Anything outside the above pattern set returns the JSON 404 from the +root handler. diff --git a/docs/reference/releases.md b/docs/reference/releases.md new file mode 100644 index 0000000..bc65275 --- /dev/null +++ b/docs/reference/releases.md @@ -0,0 +1,76 @@ +--- +title: Releases +description: Version policy, container image tags, migration notes for api-test. +--- + +# Releases + +api-test follows semver. `vMAJOR.MINOR.PATCH`: + +- **Major** — breaking changes to config keys, endpoint paths, or the + audit schema. +- **Minor** — new endpoint groups, new auth modes, new portal pages. + Backward-compatible config and schema migrations. +- **Patch** — bug fixes, documentation, internal refactors. + +## Container tags + +Every release publishes: + +- `vX.Y.Z` — exact version. Use this for reproducible deployments. +- `vX.Y` — latest patch in the minor line. Auto-tracks security fixes. +- `vX` — latest minor in the major line. Auto-tracks new features. +- `latest` — HEAD of `main`. **Not** for production. + +All multi-arch (linux/amd64, linux/arm64) and signed via cosign. + +## Migration notes + +### Unreleased / pre-1.0 + +api-test is currently pre-1.0 and shipping milestones in sequence +(M1 → M5). Each milestone may rename config keys or rework schema +without a major-version bump. Pin to a specific commit if you need +stability before 1.0. + +The current milestone status: + +| Milestone | Scope | Status | +| --- | --- | --- | +| M1 | HTTP fixture skeleton; identity / data / failure / echo groups | Released | +| M2 | DB + audit + non-OAuth inbound auth (file API key, bearer, DB key) | Released | +| M3 | Portal + React SPA + Keycloak + OIDC JWT validator + browser OIDC PKCE | In progress | +| M4 | In-tree OpenAPI generator + remaining groups (pagination, methods, security, export, streaming) | Planned | +| M5 | Docs + CI + goreleaser + k8s examples + plexara-connection.yaml | Planned | + +## Where to find releases + +- [GitHub releases](https://github.com/plexara/api-test/releases) — + binary tarballs, checksums, cosign signatures, release notes. +- [GHCR container registry](https://github.com/plexara/api-test/pkgs/container/api-test) — + multi-arch images, all tags. +- [git tags](https://github.com/plexara/api-test/tags) — `vX.Y.Z`. + +## Upgrade workflow + +For a `vX.Y.Z` → `vX.Y.Z+1` patch upgrade: + +1. Pull the new image / binary. +2. Roll the deployment. Migrations run automatically on boot. +3. Verify `/healthz` and `/readyz` come back 200. + +For a `vX.Y` → `vX.(Y+1)` minor upgrade: + +1. Read the release notes for new config keys; backfill any that + moved from "off by default" to "on by default". +2. Run the binary against your existing config; migrations apply + forward-only. +3. Roll the deployment; readiness gating handles the in-flight + request drain. + +For a `vX` → `v(X+1)` major upgrade: + +1. Read the migration notes section in the release notes carefully. +2. Test against staging first. +3. Plan a maintenance window if the audit schema migration needs to + rebuild indexes. diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..0fc1548 --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://api-test.plexara.io/sitemap.xml diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..cfeb8ee --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,2392 @@ +/* =========================================================================== + * mcp-test docs theme. Tracks the Plexara DESIGN.md identity: + * midnight neutrals (#0f172a, #020617), single teal-azure accent ("copper" + * / primary ramp), Outfit display + DM Sans body, woven diagonal-line + * pattern + radial glow as the only decorative atmospherics. + * + * Implementation notes: + * - We layer on top of Material for MkDocs by setting --md-* CSS variables + * and patching specific selectors. Material's default chrome is replaced + * selectively, not wholesale. + * - Fonts are self-hosted woff2 (variable, font-display: swap), copied + * verbatim from the Plexara marketing site so the docs feel like the + * same product. + * - Signature animations (weaveShift, glowPulse, slideUp, fadeIn) match + * the marketing site frame-for-frame. + * + * Tokens map to DESIGN.md as: + * --copper-* = teal/azure primary ramp (50..950) + * --midnight-* = neutral midnight ramp (50..950) + * ========================================================================= */ + +/* --- fonts -------------------------------------------------------------- */ + +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("../fonts/dm-sans-latin.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "DM Sans"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("../fonts/dm-sans-latin-ext.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Outfit"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("../fonts/outfit-latin.woff2") format("woff2"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, + U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Outfit"; + font-style: normal; + font-weight: 400 800; + font-display: swap; + src: url("../fonts/outfit-latin-ext.woff2") format("woff2"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, + U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, + U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* --- token scales ------------------------------------------------------- */ + +:root { + /* Plexara teal-azure ramp. Matches tailwind.config.js "copper" exactly. */ + --copper-50: #f0fdfc; + --copper-100: #ccfbf6; + --copper-200: #9af5ed; + --copper-300: #5fe8df; + --copper-400: #2dd3cb; + --copper-500: #14b8ab; + --copper-600: #149bab; + --copper-700: #1388ae; + --copper-800: #0f77b2; + --copper-900: #0c5f8e; + --copper-950: #073d5c; + + --midnight-50: #f8fafc; + --midnight-100: #f1f5f9; + --midnight-200: #e2e8f0; + --midnight-300: #cbd5e1; + --midnight-400: #94a3b8; + --midnight-500: #64748b; + --midnight-600: #475569; + --midnight-700: #334155; + --midnight-800: #1e293b; + --midnight-900: #0f172a; + --midnight-950: #020617; + + --plex-radius-sm: 6px; + --plex-radius-md: 12px; + --plex-radius-lg: 16px; + --plex-radius-xl: 24px; + + /* Container max width. Material sets root font-size to 20px, which makes + "76rem" evaluate to 1520px and breaks layout. Use fixed pixels. */ + --plex-container-max: 1280px; + --plex-container-pad: 24px; + +} + +/* Light scheme tokens. Wrapped under [data-md-color-scheme="default"] so they + beat Material's stock light scheme on specificity. */ +[data-md-color-scheme="default"] { + --md-primary-fg-color: var(--copper-800); + --md-primary-fg-color--light: var(--copper-600); + --md-primary-fg-color--dark: var(--copper-900); + --md-primary-bg-color: #ffffff; + --md-primary-bg-color--light: #ffffffd1; + + --md-accent-fg-color: var(--copper-500); + --md-accent-fg-color--transparent: rgba(20, 184, 171, 0.12); + --md-accent-bg-color: #ffffff; + --md-accent-bg-color--light: #ffffffd1; + + --md-default-fg-color: var(--midnight-900); + --md-default-fg-color--light: var(--midnight-600); + --md-default-fg-color--lighter: var(--midnight-400); + --md-default-fg-color--lightest:var(--midnight-200); + --md-default-bg-color: #ffffff; + --md-default-bg-color--light: var(--midnight-50); + --md-default-bg-color--lighter: var(--midnight-100); + + --md-typeset-a-color: var(--copper-800); + --md-code-bg-color: var(--midnight-50); + --md-code-fg-color: var(--copper-800); + + --md-footer-bg-color: var(--midnight-950); + --md-footer-bg-color--dark: #000000; + --md-footer-fg-color: #f1f5f9; + --md-footer-fg-color--light:#94a3b8; + --md-footer-fg-color--lighter: var(--midnight-700); +} + +/* Dark scheme: lift the accent up the ramp for contrast against midnight-950. */ +[data-md-color-scheme="slate"] { + --md-hue: 222; + + --md-primary-fg-color: var(--midnight-950); + --md-primary-fg-color--light: var(--midnight-900); + --md-primary-fg-color--dark: #000000; + --md-primary-bg-color: #f1f5f9; + --md-primary-bg-color--light: rgba(241, 245, 249, 0.78); + + --md-accent-fg-color: var(--copper-400); + --md-accent-fg-color--transparent: rgba(45, 211, 203, 0.12); + + --md-default-fg-color: #f1f5f9; + --md-default-fg-color--light: #cbd5e1; + --md-default-fg-color--lighter: var(--midnight-400); + --md-default-fg-color--lightest:var(--midnight-700); + --md-default-bg-color: var(--midnight-950); + --md-default-bg-color--light: var(--midnight-900); + --md-default-bg-color--lighter: var(--midnight-800); + --md-default-bg-color--lightest:var(--midnight-700); + + --md-typeset-a-color: var(--copper-400); + --md-code-bg-color: var(--midnight-900); + --md-code-fg-color: var(--copper-300); + --md-typeset-mark-color: rgba(45, 211, 203, 0.25); + + --md-footer-bg-color: #000000; + --md-footer-bg-color--dark: #000000; + --md-footer-fg-color: #f1f5f9; + --md-footer-fg-color--light:#cbd5e1; +} + +/* --- base ---------------------------------------------------------------- */ + +html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body, +.md-typeset { + font-family: "DM Sans", system-ui, -apple-system, sans-serif; + font-feature-settings: "ss01", "ss02"; +} + +.md-typeset h1, +.md-typeset h2, +.md-typeset h3, +.md-typeset h4, +.md-typeset h5, +.md-typeset h6, +.md-header, +.md-tabs, +.md-nav__title { + font-family: "Outfit", system-ui, -apple-system, sans-serif; + font-feature-settings: "liga", "calt"; +} + +.md-typeset h1 { + font-size: 2.4rem; + letter-spacing: -0.02em; + font-weight: 600; + line-height: 1.1; + color: var(--md-default-fg-color); +} +.md-typeset h2 { + font-size: 1.7rem; + letter-spacing: -0.015em; + font-weight: 600; + line-height: 1.2; + margin-top: 3rem; + position: relative; + padding-top: 0.6rem; +} +.md-typeset h2::before { + /* small copper square eyebrow above each h2 */ + content: ""; + position: absolute; + top: 0; + left: 0; + width: 22px; + height: 2px; + background: linear-gradient(to right, var(--copper-500), transparent); + border-radius: 2px; +} +.md-typeset h3 { + font-size: 1.25rem; + letter-spacing: -0.01em; + font-weight: 600; + margin-top: 2rem; +} +.md-typeset h4 { + font-size: 1.05rem; + font-weight: 600; +} + +[data-md-color-scheme="slate"] .md-typeset h1, +[data-md-color-scheme="slate"] .md-typeset h2, +[data-md-color-scheme="slate"] .md-typeset h3 { + color: #ffffff; +} + +.md-typeset p, +.md-typeset li { + line-height: 1.7; +} + +.md-typeset a { + color: var(--md-typeset-a-color); + text-decoration-color: var(--md-default-fg-color--lightest); + text-decoration-thickness: 1px; + text-underline-offset: 3px; + transition: color 120ms ease, text-decoration-color 120ms ease; +} +.md-typeset a:hover { + color: var(--copper-600); + text-decoration-color: currentColor; +} +[data-md-color-scheme="slate"] .md-typeset a:hover { + color: var(--copper-300); +} + +/* --- header (top bar) --------------------------------------------------- */ + +.md-header { + background: rgba(255, 255, 255, 0.78); + backdrop-filter: saturate(180%) blur(14px); + -webkit-backdrop-filter: saturate(180%) blur(14px); + box-shadow: none; + border-bottom: 1px solid transparent; + transition: background 200ms ease, border-color 200ms ease, box-shadow 200ms ease; + color: var(--md-default-fg-color); + font-weight: 500; +} +[data-md-color-scheme="slate"] .md-header { + background: rgba(2, 6, 23, 0.78); + color: #f1f5f9; +} +.md-header[data-md-state="shadow"], +.md-header--shadow { + box-shadow: 0 1px 0 0 rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04); + border-bottom-color: var(--md-default-fg-color--lightest); +} +[data-md-color-scheme="slate"] .md-header[data-md-state="shadow"], +[data-md-color-scheme="slate"] .md-header--shadow { + border-bottom-color: var(--midnight-800); + box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.4); +} +.md-header__title { + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.005em; +} +.md-header__topic:first-child { + font-weight: 600; +} +.md-header__button.md-logo img, +.md-header__button.md-logo svg { + width: 1.5rem; + height: 1.5rem; + filter: drop-shadow(0 0 8px rgba(20, 184, 171, 0.25)); +} + +.md-search__form { + background: rgba(255, 255, 255, 0.7); + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: var(--plex-radius-md); + transition: border-color 120ms ease, background 120ms ease; +} +.md-search__form:hover, +[data-md-toggle="search"]:checked ~ .md-header .md-search__form { + background: #ffffff; + border-color: var(--copper-500); +} +[data-md-color-scheme="slate"] .md-search__form { + background: rgba(15, 23, 42, 0.6); + border-color: var(--midnight-700); +} +[data-md-color-scheme="slate"] .md-search__form:hover, +[data-md-color-scheme="slate"] [data-md-toggle="search"]:checked ~ .md-header .md-search__form { + background: var(--midnight-900); + border-color: var(--copper-400); +} + +/* Top hairline rule on the header (Plexara signature). */ +.md-header::after { + content: ""; + position: absolute; + inset: auto 0 0 0; + height: 1px; + background: linear-gradient( + to right, + transparent, + var(--copper-500) 20%, + var(--copper-500) 80%, + transparent + ); + opacity: 0.4; + pointer-events: none; +} + +/* --- nav tabs ----------------------------------------------------------- */ + +.md-tabs { + background: var(--md-default-bg-color); + color: var(--md-default-fg-color); + border-bottom: 1px solid var(--md-default-fg-color--lightest); + font-weight: 500; +} +[data-md-color-scheme="slate"] .md-tabs { + background: var(--midnight-950); + border-bottom-color: var(--midnight-800); +} +.md-tabs__link { + opacity: 1; + color: var(--md-default-fg-color--light); + font-size: 0.78rem; + letter-spacing: -0.005em; + transition: color 120ms ease; +} +.md-tabs__link--active, +.md-tabs__link:hover { + color: var(--copper-700); +} +[data-md-color-scheme="slate"] .md-tabs__link--active, +[data-md-color-scheme="slate"] .md-tabs__link:hover { + color: var(--copper-400); +} + +/* --- side nav ----------------------------------------------------------- */ + +.md-nav { + font-size: 0.78rem; +} +.md-nav__title { + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--md-default-fg-color--light); +} +.md-nav__link { + color: var(--md-default-fg-color--light); + border-radius: 6px; + padding: 0.25rem 0.5rem; + margin: 0 0 0 -0.5rem; + transition: color 120ms ease, background 120ms ease; +} +.md-nav__link:hover, +.md-nav__link:focus { + color: var(--copper-700); + background: rgba(20, 184, 171, 0.06); +} +[data-md-color-scheme="slate"] .md-nav__link:hover, +[data-md-color-scheme="slate"] .md-nav__link:focus { + color: var(--copper-300); + background: rgba(45, 211, 203, 0.08); +} +.md-nav__link--active, +.md-nav__link--active:focus, +.md-nav__link--active:hover { + color: var(--copper-800) !important; + font-weight: 600; + background: rgba(20, 184, 171, 0.08); +} +[data-md-color-scheme="slate"] .md-nav__link--active, +[data-md-color-scheme="slate"] .md-nav__link--active:focus, +[data-md-color-scheme="slate"] .md-nav__link--active:hover { + color: var(--copper-300) !important; + background: rgba(45, 211, 203, 0.1); +} + +.md-nav--secondary .md-nav__title { + background-color: transparent; + box-shadow: none; +} + +/* --- woven pattern + hero glow (signature atmospherics) ----------------- */ + +.plex-woven { + position: absolute; + inset: -100px; + background-image: + repeating-linear-gradient(55deg, transparent 0 35px, rgba(20, 155, 171, 0.04) 35px 36px), + repeating-linear-gradient(-55deg, transparent 0 35px, rgba(19, 136, 174, 0.04) 35px 36px), + repeating-linear-gradient(0deg, transparent 0 70px, rgba(15, 119, 178, 0.02) 70px 71px); + will-change: transform; + animation: plex-weave-shift 60s linear infinite; + pointer-events: none; +} +[data-md-color-scheme="slate"] .plex-woven { + background-image: + repeating-linear-gradient(55deg, transparent 0 35px, rgba(20, 155, 171, 0.07) 35px 36px), + repeating-linear-gradient(-55deg, transparent 0 35px, rgba(19, 136, 174, 0.07) 35px 36px), + repeating-linear-gradient(0deg, transparent 0 70px, rgba(15, 119, 178, 0.04) 70px 71px); +} +@keyframes plex-weave-shift { + 0% { transform: translate3d(0, 0, 0); } + 100% { transform: translate3d(71px, 71px, 0); } +} + +.plex-glow { + background: radial-gradient( + ellipse at center, + rgba(20, 155, 171, 0.22) 0%, + rgba(15, 119, 178, 0.10) 40%, + transparent 70% + ); + will-change: transform, opacity; + animation: plex-glow-pulse 6s ease-in-out infinite; + pointer-events: none; +} +[data-md-color-scheme="slate"] .plex-glow { + background: radial-gradient( + ellipse at center, + rgba(20, 155, 171, 0.32) 0%, + rgba(15, 119, 178, 0.16) 40%, + transparent 70% + ); +} +@keyframes plex-glow-pulse { + 0%, 100% { opacity: 0.55; transform: scale(1); } + 50% { opacity: 0.85; transform: scale(1.05); } +} + +.plex-rule-top, +.plex-rule-bottom { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(to right, transparent, var(--copper-500), transparent); + pointer-events: none; +} +.plex-rule-top { top: 0; opacity: 0.7; } +.plex-rule-bottom { bottom: 0; opacity: 0.4; } + +/* --- home hero ---------------------------------------------------------- */ + +.plex-hero { + position: relative; + overflow: hidden; + background: var(--md-default-bg-color); + padding: 5rem 0 6rem; +} +[data-md-color-scheme="slate"] .plex-hero { + background: var(--midnight-950); +} +@media (min-width: 768px) { + .plex-hero { padding: 7rem 0 8rem; } +} + +.plex-hero__inner { + position: relative; + max-width: var(--plex-container-max); + margin: 0 auto; + padding: 0 var(--plex-container-pad); + display: grid; + grid-template-columns: 1fr; + gap: 2.5rem; + align-items: center; +} +@media (min-width: 1024px) { + .plex-hero__inner { + grid-template-columns: 1.15fr 0.85fr; + gap: 3rem; + padding: 0 32px; + } +} + +.plex-eyebrow { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--copper-700); + margin: 0 0 1.25rem; + animation: plex-fade-in 0.6s ease-out both; +} +[data-md-color-scheme="slate"] .plex-eyebrow { + color: var(--copper-400); +} +.plex-eyebrow::before { + content: ""; + display: inline-block; + width: 28px; + height: 1px; + background: currentColor; +} + +.plex-hero__title { + font-family: "Outfit", system-ui, sans-serif; + font-feature-settings: "liga", "calt"; + font-weight: 600; + font-size: clamp(2.25rem, 4vw + 1rem, 3.75rem); + line-height: 1.05; + letter-spacing: -0.022em; + color: var(--md-default-fg-color); + margin: 0; + animation: plex-slide-up 0.5s ease-out both; +} +[data-md-color-scheme="slate"] .plex-hero__title { + color: #ffffff; +} +.plex-hero__title em { + font-style: normal; + background: linear-gradient(95deg, var(--copper-500) 10%, var(--copper-700) 90%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +[data-md-color-scheme="slate"] .plex-hero__title em { + background: linear-gradient(95deg, var(--copper-300) 10%, var(--copper-500) 90%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.plex-hero__tagline { + font-family: "Outfit", system-ui, sans-serif; + font-style: italic; + font-weight: 400; + font-size: clamp(1.15rem, 1.4vw + 0.75rem, 1.6rem); + line-height: 1.3; + color: var(--copper-700); + margin: 1.5rem 0 0; + max-width: 36rem; + animation: plex-slide-up 0.5s ease-out 0.1s both; +} +[data-md-color-scheme="slate"] .plex-hero__tagline { + color: rgba(154, 245, 237, 0.9); +} + +.plex-hero__lede { + font-size: 1.05rem; + line-height: 1.7; + color: var(--md-default-fg-color--light); + margin: 1.5rem 0 0; + max-width: 36rem; + animation: plex-slide-up 0.5s ease-out 0.18s both; +} +[data-md-color-scheme="slate"] .plex-hero__lede { + color: #cbd5e1; +} + +.plex-hero__cta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 2.5rem 0 0; + animation: plex-slide-up 0.5s ease-out 0.28s both; +} + +.plex-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-radius: var(--plex-radius-md); + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.92rem; + font-weight: 500; + letter-spacing: 0.005em; + transition: background 160ms ease, color 160ms ease, border-color 160ms ease, transform 160ms ease; + text-decoration: none; + border: 1px solid transparent; + cursor: pointer; +} +.plex-btn--primary { + background: var(--copper-800); + color: #ffffff; +} +.plex-btn--primary:hover { + background: var(--copper-900); + color: #ffffff; + transform: translateY(-1px); +} +[data-md-color-scheme="slate"] .plex-btn--primary { + background: var(--copper-500); + color: var(--midnight-950); +} +[data-md-color-scheme="slate"] .plex-btn--primary:hover { + background: var(--copper-400); +} +.plex-btn--ghost { + background: transparent; + color: var(--md-default-fg-color); + border-color: var(--md-default-fg-color--lightest); +} +.plex-btn--ghost:hover { + background: var(--md-default-bg-color--lighter); + color: var(--copper-700); + border-color: var(--copper-500); +} +[data-md-color-scheme="slate"] .plex-btn--ghost { + color: #f1f5f9; + border-color: var(--midnight-700); +} +[data-md-color-scheme="slate"] .plex-btn--ghost:hover { + background: var(--midnight-900); + color: var(--copper-300); + border-color: var(--copper-500); +} +.plex-btn svg { + width: 1rem; + height: 1rem; + flex-shrink: 0; +} + +.plex-hero__art { + position: relative; + display: none; + align-items: center; + justify-content: center; + min-height: 22rem; +} +@media (min-width: 1024px) { + .plex-hero__art { display: flex; } +} +.plex-hero__art-glow { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; +} +.plex-hero__art-glow > .plex-glow { + width: 28rem; + height: 28rem; + border-radius: 50%; +} +.plex-hero__mark { + position: relative; + width: 18rem; + height: 18rem; + animation: plex-fade-in 0.8s ease-out both; + filter: drop-shadow(0 8px 24px rgba(20, 184, 171, 0.18)); +} +[data-md-color-scheme="slate"] .plex-hero__mark { + filter: drop-shadow(0 8px 32px rgba(45, 211, 203, 0.28)); +} + +/* --- home content sections ---------------------------------------------- */ + +.plex-section { + position: relative; + padding: 4rem 0; +} +@media (min-width: 768px) { .plex-section { padding: 6rem 0; } } +.plex-section__inner { + position: relative; + max-width: var(--plex-container-max); + margin: 0 auto; + padding: 0 var(--plex-container-pad); +} +@media (min-width: 1024px) { .plex-section__inner { padding: 0 32px; } } + +.plex-section__eyebrow { + display: block; + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.75rem; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--copper-700); + margin-bottom: 0.75rem; +} +[data-md-color-scheme="slate"] .plex-section__eyebrow { + color: var(--copper-400); +} + +.plex-section__title { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + font-size: clamp(1.6rem, 2.4vw + 0.75rem, 2.4rem); + line-height: 1.15; + letter-spacing: -0.018em; + color: var(--md-default-fg-color); + margin: 0 0 0.75rem; + max-width: 38rem; +} +[data-md-color-scheme="slate"] .plex-section__title { color: #ffffff; } + +.plex-section__intro { + font-size: 1rem; + line-height: 1.7; + color: var(--md-default-fg-color--light); + max-width: 36rem; + margin: 0; +} + +.plex-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; + margin-top: 2.5rem; +} +@media (min-width: 640px) { .plex-grid { grid-template-columns: repeat(2, 1fr); gap: 1.25rem; } } +@media (min-width: 1024px) { .plex-grid { grid-template-columns: repeat(3, 1fr); } } + +.plex-card { + position: relative; + background: var(--md-default-bg-color); + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: var(--plex-radius-lg); + padding: 1.5rem; + transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease; + overflow: hidden; +} +[data-md-color-scheme="slate"] .plex-card { + background: var(--midnight-900); + border-color: var(--midnight-800); +} +.plex-card::after { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + background: linear-gradient(to right, transparent, var(--copper-500), transparent); + opacity: 0; + transition: opacity 200ms ease; +} +.plex-card:hover { + transform: translateY(-2px); + border-color: var(--copper-500); +} +.plex-card:hover::after { opacity: 0.7; } + +.plex-card__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + border-radius: var(--plex-radius-md); + background: rgba(20, 184, 171, 0.1); + color: var(--copper-700); + margin-bottom: 1rem; +} +[data-md-color-scheme="slate"] .plex-card__icon { + background: rgba(45, 211, 203, 0.12); + color: var(--copper-300); +} +.plex-card__icon svg { width: 1.125rem; height: 1.125rem; } + +.plex-card__title { + font-family: "Outfit", system-ui, sans-serif; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: -0.005em; + color: var(--md-default-fg-color); + margin: 0 0 0.5rem; +} +[data-md-color-scheme="slate"] .plex-card__title { color: #ffffff; } +.plex-card__body { + font-size: 0.9rem; + line-height: 1.55; + color: var(--md-default-fg-color--light); + margin: 0; +} + +.plex-reasons { + list-style: none; + padding: 0; + margin: 2rem 0 0; + display: grid; + grid-template-columns: 1fr; + gap: 0.75rem; +} +@media (min-width: 768px) { .plex-reasons { grid-template-columns: 1fr 1fr; } } +.plex-reasons li { + display: flex; + gap: 0.75rem; + padding: 0.75rem 0; + border-top: 1px solid var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + font-size: 0.95rem; + line-height: 1.55; +} +[data-md-color-scheme="slate"] .plex-reasons li { + border-top-color: var(--midnight-800); + color: #f1f5f9; +} +.plex-reasons li::before { + content: ""; + flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--copper-500); + margin-top: 0.55rem; + box-shadow: 0 0 0 4px rgba(20, 184, 171, 0.12); +} +.plex-reasons code { + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + padding: 0.05rem 0.4rem; + border-radius: 4px; + font-size: 0.85em; +} + +.plex-cta-strip { + position: relative; + margin: 4rem 0 0; + padding: 3rem 1.5rem; + border-radius: var(--plex-radius-xl); + background: linear-gradient(135deg, var(--midnight-900) 0%, var(--midnight-950) 100%); + color: #ffffff; + overflow: hidden; +} +.plex-cta-strip::before { + content: ""; + position: absolute; + inset: -50%; + background: + repeating-linear-gradient(55deg, transparent 0 35px, rgba(20, 155, 171, 0.05) 35px 36px), + repeating-linear-gradient(-55deg, transparent 0 35px, rgba(19, 136, 174, 0.05) 35px 36px); + animation: plex-weave-shift 90s linear infinite; + pointer-events: none; +} +.plex-cta-strip__inner { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1.5rem; +} +.plex-cta-strip h3 { + font-family: "Outfit", system-ui, sans-serif; + font-size: 1.5rem; + font-weight: 600; + letter-spacing: -0.012em; + margin: 0 0 0.5rem; + color: #ffffff; +} +.plex-cta-strip p { + margin: 0; + color: #cbd5e1; + max-width: 32rem; +} + +/* --- code blocks -------------------------------------------------------- */ + +.md-typeset code { + font-feature-settings: "liga" 0; + font-size: 0.86em; + background: var(--md-code-bg-color); + color: var(--md-code-fg-color); + border-radius: 4px; + padding: 0.08em 0.4em; +} + +.md-typeset pre { + border-radius: var(--plex-radius-md); + overflow: hidden; + border: 1px solid var(--md-default-fg-color--lightest); + background: var(--midnight-950); + position: relative; +} +[data-md-color-scheme="slate"] .md-typeset pre { + background: var(--midnight-900); + border-color: var(--midnight-800); +} +.md-typeset pre > code { + background: transparent !important; + color: #e2e8f0 !important; + padding: 1rem 1.1rem !important; + display: block; + font-size: 0.82rem; + line-height: 1.6; +} + +.md-typeset pre::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + background: linear-gradient(to right, transparent, var(--copper-500), transparent); + opacity: 0.45; + z-index: 1; +} + +.md-typeset h1 code, +.md-typeset h2 code, +.md-typeset h3 code { + font-size: 0.86em; + background: var(--md-code-bg-color); +} + +.md-clipboard { + color: var(--midnight-400); +} +.md-clipboard:hover { + color: var(--copper-300); +} + +/* --- admonitions -------------------------------------------------------- */ + +.md-typeset .admonition, +.md-typeset details { + border-radius: var(--plex-radius-md); + border: 1px solid var(--md-default-fg-color--lightest); + border-left-width: 3px; + background: var(--md-default-bg-color); + box-shadow: none; + font-size: 0.92rem; +} +[data-md-color-scheme="slate"] .md-typeset .admonition, +[data-md-color-scheme="slate"] .md-typeset details { + background: var(--midnight-900); + border-color: var(--midnight-800); +} +.md-typeset .admonition-title, +.md-typeset summary { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + letter-spacing: -0.005em; + background: transparent !important; + border: none; + padding: 0.6rem 0.75rem 0.6rem 2.4rem; +} +.md-typeset .admonition.note, +.md-typeset details.note { border-left-color: var(--copper-600); } +.md-typeset .admonition.tip, +.md-typeset details.tip { border-left-color: var(--copper-500); } +.md-typeset .admonition.info, +.md-typeset details.info { border-left-color: var(--copper-700); } +.md-typeset .admonition.warning, +.md-typeset details.warning { border-left-color: #f59e0b; } +.md-typeset .admonition.danger, +.md-typeset details.danger { border-left-color: #dc2626; } + +/* --- tables ------------------------------------------------------------- */ + +.md-typeset table:not([class]) { + border-radius: var(--plex-radius-md); + border: 1px solid var(--md-default-fg-color--lightest); + border-collapse: separate; + border-spacing: 0; + overflow: hidden; + font-size: 0.88rem; + box-shadow: none; +} +[data-md-color-scheme="slate"] .md-typeset table:not([class]) { + border-color: var(--midnight-800); +} +.md-typeset table:not([class]) th { + background: var(--midnight-50); + color: var(--midnight-700); + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + letter-spacing: 0.005em; + border-bottom: 1px solid var(--md-default-fg-color--lightest); + padding: 0.7rem 1rem; +} +[data-md-color-scheme="slate"] .md-typeset table:not([class]) th { + background: var(--midnight-900); + color: #f1f5f9; + border-bottom-color: var(--midnight-800); +} +.md-typeset table:not([class]) td { + border-top: 1px solid var(--md-default-fg-color--lightest); + padding: 0.65rem 1rem; +} +[data-md-color-scheme="slate"] .md-typeset table:not([class]) td { + border-top-color: var(--midnight-800); +} +.md-typeset table:not([class]) tr:hover td { + background: rgba(20, 184, 171, 0.04); +} +[data-md-color-scheme="slate"] .md-typeset table:not([class]) tr:hover td { + background: rgba(45, 211, 203, 0.05); +} + +/* --- blockquotes -------------------------------------------------------- */ + +.md-typeset blockquote { + border-left: 2px solid var(--copper-500); + background: rgba(20, 184, 171, 0.04); + border-radius: 0 var(--plex-radius-sm) var(--plex-radius-sm) 0; + padding: 0.75rem 1rem; + color: var(--md-default-fg-color); + font-style: normal; +} +[data-md-color-scheme="slate"] .md-typeset blockquote { + background: rgba(45, 211, 203, 0.06); +} + +/* --- search results ----------------------------------------------------- */ + +.md-search-result__meta { + background: transparent; + color: var(--md-default-fg-color--light); + font-family: "Outfit", system-ui, sans-serif; + letter-spacing: 0.005em; +} +.md-search-result__article--document .md-search-result__title { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; +} +.md-search-result__teaser mark { + background: rgba(20, 184, 171, 0.18); + color: inherit; +} + +/* --- buttons (in-content) ----------------------------------------------- */ + +.md-typeset .md-button { + border-radius: var(--plex-radius-md); + font-family: "DM Sans", system-ui, sans-serif; + font-weight: 500; + letter-spacing: 0.005em; + padding: 0.6rem 1.1rem; + font-size: 0.92rem; + border: 1px solid var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + background: transparent; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease, transform 160ms ease; +} +.md-typeset .md-button:hover { + background: var(--md-default-bg-color--lighter); + border-color: var(--copper-500); + color: var(--copper-700); + transform: translateY(-1px); +} +.md-typeset .md-button--primary { + background: var(--copper-800); + color: #ffffff; + border-color: var(--copper-800); +} +.md-typeset .md-button--primary:hover { + background: var(--copper-900); + border-color: var(--copper-900); + color: #ffffff; +} +[data-md-color-scheme="slate"] .md-typeset .md-button--primary { + background: var(--copper-500); + color: var(--midnight-950); + border-color: var(--copper-500); +} +[data-md-color-scheme="slate"] .md-typeset .md-button--primary:hover { + background: var(--copper-400); + border-color: var(--copper-400); +} + +/* --- footer ------------------------------------------------------------- */ + +.md-footer { + background: var(--md-footer-bg-color); + color: var(--md-footer-fg-color); +} +.md-footer-meta { + background: #000000; + border-top: 1px solid var(--midnight-800); +} +.md-footer-meta .md-footer-copyright { + color: var(--md-footer-fg-color--light); +} +.md-footer__inner.md-grid, +.md-footer-meta__inner.md-grid { + max-width: var(--plex-container-max); +} +.md-footer__title { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; +} + +/* Custom plex footer (rendered by overrides/main.html). */ +.plex-footer { + background: var(--midnight-950); + color: #cbd5e1; + border-top: 1px solid var(--midnight-800); + position: relative; + overflow: hidden; +} +.plex-footer::before { + content: ""; + position: absolute; + inset: 0 0 auto 0; + height: 1px; + background: linear-gradient(to right, transparent, var(--copper-500), transparent); + opacity: 0.4; +} +.plex-footer__inner { + max-width: var(--plex-container-max); + margin: 0 auto; + padding: 3rem var(--plex-container-pad) 2rem; +} +@media (min-width: 1024px) { .plex-footer__inner { padding: 4rem 32px 2.5rem; } } + +.plex-footer__grid { + display: grid; + grid-template-columns: 1fr; + gap: 2rem; + padding-bottom: 2rem; + border-bottom: 1px solid var(--midnight-800); +} +@media (min-width: 640px) { .plex-footer__grid { grid-template-columns: repeat(2, 1fr); } } +@media (min-width: 1024px) { .plex-footer__grid { grid-template-columns: 1.4fr repeat(3, 1fr); gap: 3rem; } } + +.plex-footer__brand { + display: flex; + align-items: center; + gap: 0.75rem; + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + font-size: 1.1rem; + color: #ffffff; + margin-bottom: 0.75rem; +} +.plex-footer__brand img { + width: 1.5rem; + height: 1.5rem; + filter: drop-shadow(0 0 8px rgba(20, 184, 171, 0.35)); +} +.plex-footer__lede { + font-size: 0.9rem; + line-height: 1.6; + color: #94a3b8; + max-width: 22rem; + margin: 0; +} + +.plex-footer__col-title { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #94a3b8; + margin: 0 0 1rem; +} +.plex-footer__col ul { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.plex-footer__col a { + color: #cbd5e1; + text-decoration: none; + font-size: 0.9rem; + transition: color 120ms ease; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} +.plex-footer__col a:hover { + color: var(--copper-300); +} + +.plex-footer__bottom { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding-top: 1.5rem; + font-size: 0.8rem; + color: #94a3b8; +} +.plex-footer__bottom a { + color: var(--copper-300); + text-decoration: none; +} +.plex-footer__bottom a:hover { + color: var(--copper-200); + text-decoration: underline; + text-underline-offset: 3px; +} + +/* --- animations --------------------------------------------------------- */ + +@keyframes plex-fade-in { + 0% { opacity: 0; } + 100% { opacity: 1; } +} +@keyframes plex-slide-up { + 0% { opacity: 0; transform: translateY(16px); } + 100% { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + .plex-woven, + .plex-glow, + .plex-cta-strip::before, + .plex-eyebrow, + .plex-hero__title, + .plex-hero__tagline, + .plex-hero__lede, + .plex-hero__cta { + animation: none; + } +} + +/* --- fine-print: source-edit + back-to-top + tabbed content ------------ */ + +.md-content__button { + color: var(--md-default-fg-color--light); +} +.md-content__button:hover { + color: var(--copper-700); +} + +.md-top { + background: var(--midnight-900); + color: #ffffff; + border: 1px solid var(--midnight-800); +} +.md-top:hover { + background: var(--copper-800); + border-color: var(--copper-800); +} + +.md-typeset .tabbed-set, +.md-typeset .tabbed-alternate > .tabbed-content { + border-radius: var(--plex-radius-md); +} +.md-typeset .tabbed-labels > label { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 500; + letter-spacing: 0.005em; +} +.md-typeset .tabbed-labels > input:checked + label { + color: var(--copper-700); + border-bottom-color: var(--copper-500); +} +[data-md-color-scheme="slate"] .md-typeset .tabbed-labels > input:checked + label { + color: var(--copper-300); + border-bottom-color: var(--copper-400); +} + +/* When the home template is in use (detected by presence of .plex-hero in + the article), suppress Material's content padding so the hero runs + full-bleed. The home template itself sets the padding below the hero. */ +.md-main:has(.plex-hero) { + margin-top: 0 !important; +} +.md-main:has(.plex-hero) .md-main__inner { + margin-top: 0; + max-width: none; + display: block; +} +.md-main:has(.plex-hero) .md-content { + max-width: none; +} +.md-main:has(.plex-hero) .md-content__inner { + padding: 0; + margin: 0; + max-width: none; +} +.md-main:has(.plex-hero) .md-content__inner::before { + display: none; +} +.md-main:has(.plex-hero) > .md-main__inner > .md-sidebar { + display: none; +} + +/* Belt-and-suspenders: ensure our copper palette wins regardless of the + data-md-color-primary attribute Material applies. */ +[data-md-color-primary], +[data-md-color-accent] { + --md-primary-fg-color: var(--copper-800); + --md-primary-fg-color--light: var(--copper-600); + --md-primary-fg-color--dark: var(--copper-900); + --md-accent-fg-color: var(--copper-500); + --md-accent-fg-color--transparent: rgba(20, 184, 171, 0.12); +} +[data-md-color-scheme="slate"][data-md-color-primary], +[data-md-color-scheme="slate"][data-md-color-accent], +[data-md-color-scheme="slate"] [data-md-color-primary], +[data-md-color-scheme="slate"] [data-md-color-accent] { + --md-primary-fg-color: var(--midnight-950); + --md-primary-fg-color--light: var(--midnight-900); + --md-primary-fg-color--dark: #000000; + --md-accent-fg-color: var(--copper-400); + --md-accent-fg-color--transparent: rgba(45, 211, 203, 0.12); +} + +/* --- portal screenshots carousel --------------------------------------- * + * Horizontal scroll-snap track of dual-theme portal screenshots. Each + * slide carries both a light and a dark image; the wrong-theme one is + * hidden with a [data-md-color-scheme] selector so the carousel matches + * whatever theme the reader has selected without JS swapping src. */ + +.plex-shots { + position: relative; + padding: 3rem 0 4rem; + overflow: hidden; + border-top: 1px solid rgba(20, 184, 171, 0.08); + border-bottom: 1px solid rgba(20, 184, 171, 0.08); + background: linear-gradient( + 180deg, + var(--md-default-bg-color) 0%, + color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)) 100% + ); +} +[data-md-color-scheme="slate"] .plex-shots { + background: linear-gradient( + 180deg, + var(--midnight-950) 0%, + color-mix(in srgb, var(--midnight-950) 92%, var(--copper-700)) 100% + ); + border-color: rgba(45, 211, 203, 0.12); +} + +.plex-shots__inner { + position: relative; + max-width: var(--plex-container-max); + margin: 0 auto; + padding: 0 var(--plex-container-pad); +} +@media (min-width: 1024px) { .plex-shots__inner { padding: 0 32px; } } + +.plex-shots__head { + display: grid; + grid-template-columns: 1fr; + gap: 0.4rem; + margin-bottom: 1.5rem; + max-width: 720px; +} +.plex-shots__head h2 { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + font-size: clamp(1.4rem, 2vw + 0.75rem, 2rem); + line-height: 1.15; + letter-spacing: -0.018em; + margin: 0; + color: var(--md-default-fg-color); +} +[data-md-color-scheme="slate"] .plex-shots__head h2 { color: #fff; } +.plex-shots__lede { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.875rem; + line-height: 1.55; + color: var(--md-default-fg-color--light); + margin: 0.25rem 0 0; +} + +/* Stage = three-column grid: left rail | viewport | right rail. The rails + sit alongside the viewport so the active slide always has its + navigation immediately at the edge instead of hunting in the header. */ +.plex-shots__stage { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + position: relative; +} +@media (min-width: 1024px) { + .plex-shots__stage { gap: 1rem; } +} + +/* Side-rail nav. Buttons sit at the carousel edges, vertically centered. + They're chunkier than the old header buttons so they read as primary + navigation, not afterthoughts. */ +.plex-shots__rail { + flex: 0 0 auto; + width: 2.6rem; + height: 2.6rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: var(--md-default-bg-color); + border: 1px solid var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + cursor: pointer; + transition: border-color 180ms ease, color 180ms ease, + transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + box-shadow 180ms ease; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05), + 0 8px 24px -12px rgba(15, 23, 42, 0.18); +} +@media (min-width: 1024px) { + .plex-shots__rail { width: 3rem; height: 3rem; } +} +.plex-shots__rail svg { width: 1.25rem; height: 1.25rem; } +.plex-shots__rail:hover { + border-color: var(--copper-500); + color: var(--copper-700); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05), + 0 12px 28px -10px rgba(20, 184, 171, 0.28); +} +.plex-shots__rail--prev:hover { transform: translateX(-2px); } +.plex-shots__rail--next:hover { transform: translateX(2px); } +.plex-shots__rail:active { transform: scale(0.96); } +.plex-shots__rail:focus-visible { + outline: 2px solid var(--copper-500); + outline-offset: 3px; +} +[data-md-color-scheme="slate"] .plex-shots__rail { + background: var(--midnight-900); + border-color: var(--midnight-800); + color: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), + 0 12px 28px -16px rgba(0, 0, 0, 0.6); +} +[data-md-color-scheme="slate"] .plex-shots__rail:hover { + border-color: var(--copper-400); + color: var(--copper-300); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.45), + 0 12px 28px -10px rgba(45, 211, 203, 0.28); +} + +/* Counter under the stage: "3 / 11". Decimal-tabular figures so the digits + don't jiggle as the index changes. */ +.plex-shots__counter { + margin: 1rem auto 0; + text-align: center; + font-family: "DM Sans", system-ui, sans-serif; + font-feature-settings: "tnum" 1; + font-size: 0.7rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--md-default-fg-color--light); +} +[data-md-color-scheme="slate"] .plex-shots__counter { color: var(--midnight-300); } +.plex-shots__counter strong { + color: var(--copper-700); + font-weight: 600; + margin-right: 0.15em; +} +[data-md-color-scheme="slate"] .plex-shots__counter strong { color: var(--copper-300); } + +/* Outer viewport: clips off-screen slides. The track inside translates via + JS-computed offsets. */ +.plex-shots__viewport { + overflow: hidden; + padding: 0.5rem 0 0.75rem; +} + +.plex-shots__track { + display: flex; + gap: 1rem; + transform: translate3d(0, 0, 0); + transition: transform 520ms cubic-bezier(0.22, 1, 0.36, 1); + will-change: transform; +} +@media (min-width: 1024px) { + .plex-shots__track { gap: 1.25rem; } +} +@media (prefers-reduced-motion: reduce) { + .plex-shots__track, + .plex-shots__slide, + .plex-shots__rail, + .plex-shots__zoomhint, + .plex-shots__frame img { transition: none; } +} + +/* Slides ~30% smaller than the previous layout so neighbors are always + peeking in. Material's `.md-typeset figure { width: fit-content }` + outranks a plain `.plex-shots__slide` selector, so we double up the + class for specificity instead of reaching for !important. */ +.plex-shots__slide.plex-shots__slide { + flex: 0 0 auto; + width: min(72vw, 480px); + border-radius: var(--plex-radius-lg); + background: var(--md-default-bg-color); + border: 1px solid var(--md-default-fg-color--lightest); + display: flex; + flex-direction: column; + margin: 0; + overflow: hidden; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), + 0 12px 32px -16px rgba(15, 23, 42, 0.18); + transition: border-color 200ms ease, box-shadow 200ms ease, + opacity 520ms ease, transform 520ms cubic-bezier(0.22, 1, 0.36, 1); + opacity: 0.5; + transform: scale(0.96); +} +@media (min-width: 1024px) { + .plex-shots__slide.plex-shots__slide { width: min(40vw, 580px); } +} +.plex-shots__slide.is-active { + opacity: 1; + transform: scale(1); +} +.plex-shots__slide:hover { + border-color: var(--copper-500); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04), + 0 24px 48px -20px rgba(20, 184, 171, 0.28); +} +[data-md-color-scheme="slate"] .plex-shots__slide { + background: var(--midnight-900); + border-color: var(--midnight-800); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), + 0 24px 48px -20px rgba(0, 0, 0, 0.6); +} + +/* Frame is now an interactive button (the click target for the lightbox). + Strip default button chrome and rebuild as a flush surface. The + zoomhint badge slides in on hover/focus to telegraph the action. */ +.plex-shots__frame { + position: relative; + aspect-ratio: 1440 / 900; + background: var(--md-default-bg-color); + overflow: hidden; + border: 0; + border-bottom: 1px solid var(--md-default-fg-color--lightest); + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: zoom-in; + display: block; + width: 100%; + text-align: left; + transition: filter 220ms ease; +} +[data-md-color-scheme="slate"] .plex-shots__frame { + background: var(--midnight-950); + border-color: var(--midnight-800); +} +.plex-shots__frame img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + object-position: top center; + display: block; + transition: transform 1200ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-shots__slide.is-active .plex-shots__frame:hover img, +.plex-shots__slide.is-active .plex-shots__frame:focus-visible img { + transform: scale(1.025); +} +.plex-shots__frame:focus-visible { + outline: 2px solid var(--copper-500); + outline-offset: -2px; +} + +/* Theme-gated visibility: show the matching screenshot, hide the other. */ +.plex-shots__frame img[data-theme="dark"] { display: none; } +.plex-shots__frame img[data-theme="light"] { display: block; } +[data-md-color-scheme="slate"] .plex-shots__frame img[data-theme="light"] { display: none; } +[data-md-color-scheme="slate"] .plex-shots__frame img[data-theme="dark"] { display: block; } + +/* Zoom hint: small copper-tinted glyph in the top-right corner of the + active slide's frame. Stays subtle until hover, then lifts. */ +.plex-shots__zoomhint { + position: absolute; + top: 0.65rem; + right: 0.65rem; + width: 1.85rem; + height: 1.85rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.55); + color: #fff; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + opacity: 0; + transform: translateY(-4px); + transition: opacity 180ms ease, transform 220ms cubic-bezier(0.22, 1, 0.36, 1), + background-color 180ms ease; + pointer-events: none; +} +.plex-shots__zoomhint svg { width: 1rem; height: 1rem; } +.plex-shots__slide.is-active .plex-shots__zoomhint { opacity: 0.85; transform: translateY(0); } +.plex-shots__frame:hover .plex-shots__zoomhint, +.plex-shots__frame:focus-visible .plex-shots__zoomhint { + opacity: 1; + background: var(--copper-700); +} + +.plex-shots__caption { + padding: 1rem 1.25rem 1.1rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + font-style: normal; +} +.plex-shots__caption .eyebrow { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.7rem; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--copper-700); + font-style: normal; +} +[data-md-color-scheme="slate"] .plex-shots__caption .eyebrow { color: var(--copper-400); } +.plex-shots__caption h3 { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + font-size: 1.05rem; + margin: 0; + color: var(--md-default-fg-color); + letter-spacing: -0.01em; + font-style: normal; +} +[data-md-color-scheme="slate"] .plex-shots__caption h3 { color: #fff; } +.plex-shots__caption p { + font-size: 0.875rem; + line-height: 1.55; + color: var(--md-default-fg-color--light); + margin: 0; + font-style: normal; +} + +@media (prefers-reduced-motion: reduce) { + .plex-shots__track { scroll-behavior: auto; } +} + +/* ───────────────────────────────────────────────────────────────────────── + Lightbox: focused-artifact view of a single screenshot. + + Aesthetic: the rest of the page recedes behind a deep midnight backdrop + with a faint copper haze; the screenshot floats as a single object + inside a panel that uses the same border / radius / shadow vocabulary + as the carousel slides, just sized up. Caption sits below the image + in the same Outfit + DM Sans typography so the modal feels like part + of the document, not a foreign overlay. Motion vocabulary mirrors the + carousel (same cubic-bezier(0.22, 1, 0.36, 1)) so opening one feels + like the carousel zooming into focus. + ───────────────────────────────────────────────────────────────────── */ + +.plex-lightbox { + position: fixed; + inset: 0; + z-index: 9999; + display: grid; + place-items: center; + padding: clamp(0.75rem, 2vw, 2rem); + opacity: 0; + pointer-events: none; + transition: opacity 220ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-lightbox[hidden] { display: none; } +.plex-lightbox.is-open { + opacity: 1; + pointer-events: auto; +} + +.plex-lightbox__backdrop { + position: absolute; + inset: 0; + background: + radial-gradient( + ellipse at 50% 20%, + rgba(20, 184, 171, 0.10) 0%, + transparent 60% + ), + rgba(7, 18, 36, 0.78); + backdrop-filter: blur(8px) saturate(120%); + -webkit-backdrop-filter: blur(8px) saturate(120%); + cursor: zoom-out; +} +[data-md-color-scheme="slate"] .plex-lightbox__backdrop { + background: + radial-gradient( + ellipse at 50% 20%, + rgba(45, 211, 203, 0.13) 0%, + transparent 60% + ), + rgba(2, 6, 12, 0.82); +} + +.plex-lightbox__shell { + position: relative; + width: min(96vw, 1480px); + max-height: 92vh; + display: flex; + flex-direction: column; + background: var(--md-default-bg-color); + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: var(--plex-radius-lg); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.04) inset, + 0 24px 60px -20px rgba(0, 0, 0, 0.55), + 0 64px 120px -40px rgba(20, 184, 171, 0.18); + overflow: hidden; + transform: translateY(8px) scale(0.98); + transition: transform 260ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-lightbox.is-open .plex-lightbox__shell { transform: translateY(0) scale(1); } +[data-md-color-scheme="slate"] .plex-lightbox__shell { + background: var(--midnight-950); + border-color: var(--midnight-800); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.03) inset, + 0 24px 60px -20px rgba(0, 0, 0, 0.7), + 0 64px 120px -40px rgba(45, 211, 203, 0.18); +} + +/* Top bar: title on the left, count + nav + close on the right. Compact + toolbar height (~3.25rem) so the screenshot, not the chrome, is + what dominates the viewport. Material's `.md-typeset h3` and span + defaults are aggressive on margins; the rules below use element- + qualified selectors so they tie on specificity (0,1,1) and source + order picks the lightbox style as the winner. */ +.plex-lightbox__bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.55rem 0.65rem 0.55rem 1rem; + min-height: 0; + border-bottom: 1px solid var(--md-default-fg-color--lightest); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)) 0%, + var(--md-default-bg-color) 100% + ); +} +[data-md-color-scheme="slate"] .plex-lightbox__bar { + border-color: var(--midnight-800); + background: linear-gradient( + 180deg, + color-mix(in srgb, var(--midnight-900) 88%, var(--copper-700)) 0%, + var(--midnight-900) 100% + ); +} + +.plex-lightbox__meta { + display: flex; + align-items: baseline; + gap: 0.6rem; + min-width: 0; + flex: 1 1 auto; +} +/* Element-qualified selectors below: Material's `.md-typeset h3` + (0,1,1) and span defaults beat single-class rules on specificity, so + we tag the element explicitly to win. */ +span.plex-lightbox__eyebrow { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.65rem; + font-weight: 500; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--copper-700); + margin: 0; + line-height: 1; + flex: 0 0 auto; +} +[data-md-color-scheme="slate"] span.plex-lightbox__eyebrow { color: var(--copper-300); } +h3.plex-lightbox__title { + font-family: "Outfit", system-ui, sans-serif; + font-weight: 600; + font-size: 0.95rem; + letter-spacing: -0.012em; + margin: 0; + padding: 0; + color: var(--md-default-fg-color); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1 1 auto; +} +@media (min-width: 720px) { + h3.plex-lightbox__title { font-size: 1.05rem; } +} +[data-md-color-scheme="slate"] h3.plex-lightbox__title { color: #fff; } + +.plex-lightbox__controls { + display: flex; + align-items: center; + gap: 0.4rem; +} +.plex-lightbox__count { + font-family: "DM Sans", system-ui, sans-serif; + font-feature-settings: "tnum" 1; + font-size: 0.7rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--md-default-fg-color--light); + margin-right: 0.35rem; +} +[data-md-color-scheme="slate"] .plex-lightbox__count { color: var(--midnight-300); } + +.plex-lightbox__navbtn, +.plex-lightbox__close { + width: 2.1rem; + height: 2.1rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: transparent; + border: 1px solid var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color); + cursor: pointer; + transition: border-color 160ms ease, color 160ms ease, + background-color 160ms ease, transform 200ms cubic-bezier(0.22, 1, 0.36, 1); +} +.plex-lightbox__navbtn svg, +.plex-lightbox__close svg { width: 1rem; height: 1rem; } +.plex-lightbox__navbtn:hover, +.plex-lightbox__close:hover { + border-color: var(--copper-500); + color: var(--copper-700); + background: color-mix(in srgb, var(--copper-500) 8%, transparent); +} +.plex-lightbox__navbtn:active, +.plex-lightbox__close:active { transform: scale(0.94); } +.plex-lightbox__navbtn:focus-visible, +.plex-lightbox__close:focus-visible { + outline: 2px solid var(--copper-500); + outline-offset: 2px; +} +[data-md-color-scheme="slate"] .plex-lightbox__navbtn, +[data-md-color-scheme="slate"] .plex-lightbox__close { + border-color: var(--midnight-700); + color: #fff; +} +[data-md-color-scheme="slate"] .plex-lightbox__navbtn:hover, +[data-md-color-scheme="slate"] .plex-lightbox__close:hover { + border-color: var(--copper-400); + color: var(--copper-300); + background: color-mix(in srgb, var(--copper-500) 12%, transparent); +} + +/* Stage: the screenshot itself. We use object-fit:contain so the entire + image fits without cropping; aspect-ratio keeps the panel proportional + so a missing image doesn't collapse the layout. */ +.plex-lightbox__stage { + position: relative; + margin: 0; + padding: clamp(0.75rem, 1.5vw, 1.5rem); + display: flex; + flex-direction: column; + gap: 0.85rem; + min-height: 0; + flex: 1 1 auto; + overflow: auto; + background: linear-gradient( + 180deg, + var(--md-default-bg-color) 0%, + color-mix(in srgb, var(--md-default-bg-color) 92%, var(--copper-500)) 100% + ); +} +[data-md-color-scheme="slate"] .plex-lightbox__stage { + background: linear-gradient( + 180deg, + var(--midnight-950) 0%, + color-mix(in srgb, var(--midnight-950) 86%, var(--copper-700)) 100% + ); +} + +.plex-lightbox__img { + display: block; + width: 100%; + height: auto; + /* Budget ~5.5rem for the toolbar + caption + stage padding so the + screenshot owns the rest. Was 9rem when the bar was ~3x taller. */ + max-height: calc(92vh - 5.5rem); + object-fit: contain; + border-radius: calc(var(--plex-radius-lg) - 6px); + border: 1px solid var(--md-default-fg-color--lightest); + background: var(--md-default-bg-color); + box-shadow: 0 12px 32px -16px rgba(15, 23, 42, 0.35); +} +[data-md-color-scheme="slate"] .plex-lightbox__img { + border-color: var(--midnight-800); + background: var(--midnight-900); + box-shadow: 0 12px 32px -16px rgba(0, 0, 0, 0.6); +} + +/* Theme-gated visibility for the two stacked img tags inside the + lightbox stage. Same pattern as the carousel frame. */ +.plex-lightbox__img[data-theme="dark"] { display: none; } +.plex-lightbox__img[data-theme="light"] { display: block; } +[data-md-color-scheme="slate"] .plex-lightbox__img[data-theme="light"] { display: none; } +[data-md-color-scheme="slate"] .plex-lightbox__img[data-theme="dark"] { display: block; } + +.plex-lightbox__caption { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.9rem; + line-height: 1.6; + color: var(--md-default-fg-color--light); + text-align: left; + max-width: 70ch; + margin: 0 auto; +} +[data-md-color-scheme="slate"] .plex-lightbox__caption { color: var(--midnight-200); } + +@media (max-width: 720px) { + .plex-lightbox { padding: 0; } + .plex-lightbox__shell { + width: 100vw; + max-height: 100vh; + height: 100vh; + border-radius: 0; + border: 0; + } + .plex-lightbox__bar { padding: 0.7rem 0.85rem; } + .plex-lightbox__count { display: none; } + .plex-lightbox__navbtn, + .plex-lightbox__close { width: 2rem; height: 2rem; } + .plex-lightbox__img { max-height: calc(100vh - 5.5rem); } +} + +@media (prefers-reduced-motion: reduce) { + .plex-lightbox, + .plex-lightbox__shell { + transition: none; + } +} + +/* When the lightbox is open, lock the body scroll. JS toggles this class + on . We avoid `overflow:hidden` on body alone because Material's + layout fights it. */ +html.plex-lightbox-open, +html.plex-lightbox-open body { + overflow: hidden; +} + +/* ───────────────────────────────────────────────────────────────────────── + API endpoint cards: the HTTP-API reference layout. + + Aesthetic: each endpoint is a horizontal card. Method pill on the left, + full mono path on a single no-wrap line so 50-char paths read as one + token instead of getting hyphenated mid-segment by a narrow Path + column. Description sits below in DM Sans body type, indented to align + with the path. The 3px left strip color-codes the verb in copper / + coral so the eye can scan a long list and pick out the POST / DELETE + rows without parsing each pill. + + Vocabulary borrowed from the carousel slides + lightbox shell so the + docs site reads as one designed system, not a stack of unrelated + components. + ───────────────────────────────────────────────────────────────────── */ + +/* Wrap the whole stack so the children share margins and we have a hook + for narrow-viewport tweaks. md_in_html requires `markdown` attr to + pass through; we render this in the source markdown. */ +.md-typeset .api-endpoints { + margin: 1.25rem 0 1.5rem; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.md-typeset .api-endpoint { + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + margin: 0; + padding: 0.85rem 1rem 0.9rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-left: 3px solid color-mix(in srgb, var(--copper-500) 35%, transparent); + border-radius: var(--plex-radius-md); + background: var(--md-default-bg-color); + transition: border-color 200ms ease, box-shadow 200ms ease, + background-color 200ms ease; +} +.md-typeset .api-endpoint:hover { + border-color: var(--copper-500); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 14px 28px -18px rgba(20, 184, 171, 0.22); + background: color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint { + background: var(--midnight-900); + border-color: var(--midnight-800); + border-left-color: color-mix(in srgb, var(--copper-400) 45%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint:hover { + border-color: var(--copper-400); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.4), + 0 14px 28px -18px rgba(45, 211, 203, 0.28); + background: color-mix(in srgb, var(--midnight-900) 92%, var(--copper-700)); +} + +/* Per-method left-strip accent. The color choice keeps GET in the brand + teal (read, frequent), POST one step deeper (write), DELETE in a + muted coral (destructive but not traffic-light red). Same accents + inform the method pill below. */ +.md-typeset .api-endpoint--get { border-left-color: var(--copper-400); } +.md-typeset .api-endpoint--post { border-left-color: var(--copper-700); } +.md-typeset .api-endpoint--delete { border-left-color: #b04829; } +.md-typeset .api-endpoint--put { border-left-color: var(--copper-600); } +.md-typeset .api-endpoint--patch { border-left-color: var(--copper-600); } + +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--get { border-left-color: var(--copper-300); } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--post { border-left-color: var(--copper-400); } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--delete { border-left-color: #e8836a; } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--put { border-left-color: var(--copper-300); } +[data-md-color-scheme="slate"] .md-typeset .api-endpoint--patch { border-left-color: var(--copper-300); } + +/* The header row: method pill + path on one line. flex-nowrap so the + path takes the remaining width and overflows horizontally rather + than dropping below the pill. */ +.md-typeset .api-endpoint__head { + display: flex; + align-items: center; + gap: 0.6rem; + min-width: 0; +} + +.md-typeset .api-endpoint__method { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 3.6em; + padding: 0.18rem 0.6rem; + border-radius: 999px; + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + font-feature-settings: "tnum" 1; + border: 1px solid transparent; + /* Override Material's default badge / code styling that .md-typeset + might apply via cascade. */ + background: transparent; + color: var(--md-default-fg-color); +} + +.md-typeset .api-endpoint__method--get { + background: color-mix(in srgb, var(--copper-500) 12%, transparent); + color: var(--copper-800); + border-color: color-mix(in srgb, var(--copper-500) 35%, transparent); +} +.md-typeset .api-endpoint__method--post { + background: color-mix(in srgb, var(--copper-700) 16%, transparent); + color: var(--copper-900); + border-color: color-mix(in srgb, var(--copper-700) 45%, transparent); +} +.md-typeset .api-endpoint__method--delete { + background: rgba(176, 72, 41, 0.12); + color: #8b3a23; + border-color: rgba(176, 72, 41, 0.40); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__method--get { + background: color-mix(in srgb, var(--copper-400) 18%, transparent); + color: var(--copper-200); + border-color: color-mix(in srgb, var(--copper-400) 50%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__method--post { + background: color-mix(in srgb, var(--copper-500) 22%, transparent); + color: var(--copper-100); + border-color: color-mix(in srgb, var(--copper-500) 55%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__method--delete { + background: rgba(232, 131, 106, 0.18); + color: #f0a896; + border-color: rgba(232, 131, 106, 0.45); +} + +/* The path. Critical no-wrap behavior: white-space:nowrap + + overflow-x:auto so a 60-char path is readable as a single token, + horizontally scrollable on narrow viewports rather than broken. */ +.md-typeset .api-endpoint__path { + flex: 1 1 auto; + min-width: 0; + font-family: var(--md-code-font, ui-monospace, "SF Mono", "Menlo", monospace); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: -0.005em; + color: var(--md-default-fg-color); + background: transparent; + padding: 0; + border: 0; + white-space: nowrap; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: var(--copper-500) transparent; +} +.md-typeset .api-endpoint__path::-webkit-scrollbar { height: 4px; } +.md-typeset .api-endpoint__path::-webkit-scrollbar-track { background: transparent; } +.md-typeset .api-endpoint__path::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--copper-500) 60%, transparent); + border-radius: 2px; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__path { color: #fff; } + +/* Body: description text in the same body type as the rest of the site, + indented to align under the path's left edge so the eye doesn't have + to reset. Keeps inline code, emphasis, and links rendering naturally + via md_in_html. */ +.md-typeset .api-endpoint__body { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.875rem; + line-height: 1.6; + color: var(--md-default-fg-color--light); + /* Indent under the method pill so the description aligns with where + the path text starts (3.6em min-width pill + 0.6rem gap ≈ 4.5rem). */ + padding-left: calc(3.6em + 0.6rem); + margin: 0; +} +.md-typeset .api-endpoint__body > :first-child { margin-top: 0; } +.md-typeset .api-endpoint__body > :last-child { margin-bottom: 0; } +.md-typeset .api-endpoint__body p { + margin: 0 0 0.4rem; +} +.md-typeset .api-endpoint__body p:last-child { margin-bottom: 0; } +.md-typeset .api-endpoint__body code { + /* Material's default inline-code chrome stays; just ensure it doesn't + collide visually with the path mono. */ + font-size: 0.82rem; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__body { color: var(--midnight-200); } + +/* Inline meta block for endpoints with extra structured info (Body + + Returns columns from the old Admin table). Renders as a small + key/value pair line under the description. */ +.md-typeset .api-endpoint__meta { + display: grid; + grid-template-columns: max-content 1fr; + gap: 0.25rem 0.85rem; + margin-top: 0.45rem; + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.82rem; + line-height: 1.55; +} +.md-typeset .api-endpoint__meta dt { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--copper-700); + padding-top: 0.18rem; + margin: 0; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__meta dt { color: var(--copper-300); } +.md-typeset .api-endpoint__meta dd { + margin: 0; + color: var(--md-default-fg-color--light); + min-width: 0; +} +[data-md-color-scheme="slate"] .md-typeset .api-endpoint__meta dd { color: var(--midnight-200); } + +/* Narrow-viewport: drop the body indent so the description gets the + full row width. The horizontal-scroll path stays the same. */ +@media (max-width: 720px) { + .md-typeset .api-endpoint__body { padding-left: 0; } + .md-typeset .api-endpoint__head { gap: 0.5rem; } +} + +@media (prefers-reduced-motion: reduce) { + .md-typeset .api-endpoint { transition: none; } +} + +/* ───────────────────────────────────────────────────────────────────────── + Config-key cards: same vocabulary as the API endpoint cards, used + for the YAML reference and the env-var listings. The key (e.g. + `server.streamable.session_timeout`, `MCPTEST_OIDC_CLIENT_SECRET`) + gets the top line on its own so a 40-char dotted path doesn't + hyphenate mid-segment into a narrow Key column. Type / default chips + on the right of the head; notes prose below. + ───────────────────────────────────────────────────────────────────── */ + +.md-typeset .config-keys, +.md-typeset .def-cards { + margin: 1.25rem 0 1.5rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.md-typeset .config-key, +.md-typeset .def-card { + display: grid; + grid-template-columns: 1fr; + gap: 0.45rem; + margin: 0; + padding: 0.85rem 1rem 0.9rem; + border: 1px solid var(--md-default-fg-color--lightest); + border-left: 3px solid color-mix(in srgb, var(--copper-500) 35%, transparent); + border-radius: var(--plex-radius-md); + background: var(--md-default-bg-color); + transition: border-color 200ms ease, box-shadow 200ms ease, + background-color 200ms ease; +} +.md-typeset .config-key:hover, +.md-typeset .def-card:hover { + border-color: var(--copper-500); + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 14px 28px -18px rgba(20, 184, 171, 0.22); + background: color-mix(in srgb, var(--md-default-bg-color) 96%, var(--copper-500)); +} +[data-md-color-scheme="slate"] .md-typeset .config-key, +[data-md-color-scheme="slate"] .md-typeset .def-card { + background: var(--midnight-900); + border-color: var(--midnight-800); + border-left-color: color-mix(in srgb, var(--copper-400) 45%, transparent); +} +[data-md-color-scheme="slate"] .md-typeset .config-key:hover, +[data-md-color-scheme="slate"] .md-typeset .def-card:hover { + border-color: var(--copper-400); + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.4), + 0 14px 28px -18px rgba(45, 211, 203, 0.28); + background: color-mix(in srgb, var(--midnight-900) 92%, var(--copper-700)); +} + +/* Required vs optional left strips. Most keys are optional; mark a + handful as required (e.g. `oidc.issuer` when oidc.enabled). */ +.md-typeset .config-key--required { border-left-color: var(--copper-700); } +[data-md-color-scheme="slate"] .md-typeset .config-key--required { border-left-color: var(--copper-300); } + +/* The head is a fixed two-row stack: key on row 1, chips on row 2. + Forcing a column (rather than flex-wrap) means the chips sit in the + same place on every card regardless of the key's length, which is + what the eye expects when scanning a long list. */ +.md-typeset .config-key__head, +.md-typeset .def-card__head { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.45rem; + min-width: 0; +} + +/* Key name: bold mono, copper accent. Allowed to wrap at any character + when a single segment exceeds the row, but in practice every key + here fits on one line at the standard mkdocs content width. */ +.md-typeset code.config-key__name, +.md-typeset code.def-card__name { + display: block; + width: 100%; + font-family: var(--md-code-font, ui-monospace, "SF Mono", "Menlo", monospace); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.005em; + color: var(--copper-800); + background: transparent; + padding: 0; + border: 0; + word-break: break-word; + overflow-wrap: anywhere; + white-space: normal; +} +[data-md-color-scheme="slate"] .md-typeset code.config-key__name, +[data-md-color-scheme="slate"] .md-typeset code.def-card__name { color: var(--copper-200); } + +/* Chip row: always on its own line below the key so type/default + anchor in the same horizontal slot on every card. */ +.md-typeset .config-key__chips, +.md-typeset .def-card__chips { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +.md-typeset .config-key__chip, +.md-typeset .def-card__chip { + display: inline-flex; + align-items: baseline; + gap: 0.35rem; + padding: 0.18rem 0.55rem 0.2rem; + border-radius: 999px; + border: 1px solid var(--md-default-fg-color--lightest); + background: color-mix(in srgb, var(--md-default-bg-color) 92%, var(--copper-500)); + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.72rem; + line-height: 1.2; + color: var(--md-default-fg-color); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip { + background: color-mix(in srgb, var(--midnight-900) 88%, var(--copper-700)); + border-color: var(--midnight-800); + color: #fff; +} + +.md-typeset .config-key__chip-label, +.md-typeset .def-card__chip-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--copper-700); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip-label, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip-label { color: var(--copper-300); } + +.md-typeset .config-key__chip-value, +.md-typeset .config-key__chip code, +.md-typeset .def-card__chip-value, +.md-typeset .def-card__chip code { + font-family: var(--md-code-font, ui-monospace, "SF Mono", "Menlo", monospace); + font-size: 0.78rem; + font-weight: 500; + letter-spacing: -0.005em; + color: var(--md-default-fg-color); + background: transparent; + padding: 0; + border: 0; +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip-value, +[data-md-color-scheme="slate"] .md-typeset .config-key__chip code, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip-value, +[data-md-color-scheme="slate"] .md-typeset .def-card__chip code { color: #fff; } + +/* `default` chip carries an emphasized copper border so the eye finds + the default value in a long list. */ +.md-typeset .config-key__chip--default { + border-color: color-mix(in srgb, var(--copper-500) 50%, transparent); + background: color-mix(in srgb, var(--md-default-bg-color) 88%, var(--copper-500)); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip--default { + border-color: color-mix(in srgb, var(--copper-400) 55%, transparent); + background: color-mix(in srgb, var(--midnight-900) 78%, var(--copper-700)); +} + +/* `required` chip: distinct rust tone so missing-required errors map + visually to the keys that produce them. */ +.md-typeset .config-key__chip--required { + border-color: rgba(176, 72, 41, 0.50); + background: rgba(176, 72, 41, 0.10); +} +.md-typeset .config-key__chip--required .config-key__chip-label { color: #8b3a23; } +[data-md-color-scheme="slate"] .md-typeset .config-key__chip--required { + border-color: rgba(232, 131, 106, 0.55); + background: rgba(232, 131, 106, 0.16); +} +[data-md-color-scheme="slate"] .md-typeset .config-key__chip--required .config-key__chip-label { color: #f0a896; } + +.md-typeset .config-key__body, +.md-typeset .def-card__body { + font-family: "DM Sans", system-ui, sans-serif; + font-size: 0.875rem; + line-height: 1.6; + color: var(--md-default-fg-color--light); + margin: 0; +} +.md-typeset .config-key__body > :first-child { margin-top: 0; } +.md-typeset .config-key__body > :last-child { margin-bottom: 0; } +.md-typeset .config-key__body p { margin: 0 0 0.4rem; } +.md-typeset .config-key__body p:last-child { margin-bottom: 0; } +.md-typeset .config-key__body code { font-size: 0.82rem; } +[data-md-color-scheme="slate"] .md-typeset .config-key__body, +[data-md-color-scheme="slate"] .md-typeset .def-card__body { color: var(--midnight-200); } + +@media (prefers-reduced-motion: reduce) { + .md-typeset .config-key, + .md-typeset .def-card { transition: none; } +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c05193d --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,156 @@ +site_name: api-test +# Trailing slash matters: it's used as a prefix in OG/Twitter card URLs +# and in JSON-LD, where doubling-up "//" breaks tools that don't normalize. +site_url: https://api-test.plexara.io/ +site_description: >- + api-test is a controllable HTTP REST fixture, built specifically as an + upstream for testing API gateways. Endpoint groups for identity, + deterministic data, controlled failures, pagination styles, and SSRF + probes; multiple inbound auth modes matching what the Plexara API + gateway sends; and a Postgres-backed audit log of every request. + Open source by Plexara, Apache 2.0. +site_author: Plexara +repo_url: https://github.com/plexara/api-test +repo_name: plexara/api-test +edit_uri: edit/main/docs/ + +copyright: Copyright © 2026 Plexara + +theme: + name: material + custom_dir: docs/overrides + logo: images/logo.svg + favicon: images/logo.svg + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: custom + toggle: + icon: material/weather-night + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/weather-sunny + name: Switch to light mode + font: false + icon: + repo: fontawesome/brands/github + features: + - navigation.instant + - navigation.instant.progress + - navigation.tracking + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.top + - navigation.footer + - search.suggest + - search.highlight + - search.share + - content.code.copy + - content.code.annotate + - content.tabs.link + - content.action.edit + - toc.follow + - announce.dismiss + +nav: + - Home: index.md + - Getting Started: + - Overview: getting-started/overview.md + - Installation: getting-started/installation.md + - Quickstart: getting-started/quickstart.md + - Register with Plexara: getting-started/register-with-plexara.md + - Configuration: + - YAML Reference: configuration/reference.md + - Environment Variables: configuration/environment.md + - Authentication: configuration/auth.md + - Database & Migrations: configuration/database.md + - Endpoints: + - Overview: endpoints/overview.md + - Identity: endpoints/identity.md + - Data: endpoints/data.md + - Failure Modes: endpoints/failure.md + - Echo: endpoints/echo.md + - Operations: + - Audit Log: operations/audit.md + - Portal: operations/portal.md + - Deployment: operations/deployment.md + - Testing a Gateway: operations/gateway-testing.md + - Reference: + - HTTP API: reference/http-api.md + - Architecture: reference/architecture.md + - Releases: reference/releases.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/plexara/api-test + name: api-test on GitHub + - icon: fontawesome/solid/globe + link: https://plexara.io + name: Plexara + generator: false + +extra_css: + - stylesheets/extra.css + +extra_javascript: + - javascripts/shots.js + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - footnotes + - md_in_html + - tables + - toc: + permalink: true + toc_depth: 3 + - pymdownx.betterem: + smart_enable: all + - pymdownx.caret + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.magiclink: + repo_url_shorthand: true + user: plexara + repo: api-test + - pymdownx.mark + - pymdownx.smartsymbols + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - pymdownx.tilde + +plugins: + - search: + separator: '[\s\-,:!=\[\]()"`/]+|\.(?!\d)|&[lg]t;|(?!\b)(?=[A-Z][a-z])' + # Note: mkdocs-material's `social` plugin (per-page auto-generated + # 1200x630 cards) was previously wired in here. We removed it in + # favor of a curated, hand-designed OG banner at images/og-card.png + # used site-wide via docs/overrides/main.html. The Tailwind / Stripe / + # Vercel pattern: one strong brand banner beats N generic + # auto-renders. To opt back into per-page cards, restore the plugin + # entry and re-add the imaging system deps + 'mkdocs-material[imaging]' + # to .github/workflows/docs.yml. From 017a43a0c8f7f3f3ed2c50864b5adc1d98bf7d88 Mon Sep 17 00:00:00 2001 From: cjimti Date: Sat, 9 May 2026 16:51:38 -0700 Subject: [PATCH 3/5] add CI workflows + linter / security configs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the only GitHub Actions workflow was docs.yml, so PRs landed with zero verification. Ports the rest of mcp-test's CI suite over, adapted for api-test (different module path, no UI yet, Go 1.26.3). CI: - .github/workflows/ci.yml — five jobs: lint golangci-lint v2.11.4 test race + coverage gate ≥80% + go-mod-tidy check + Codecov upload (only when CODECOV_TOKEN secret is set) build go build ./... + go mod verify security gosec v2.25.0 + govulncheck + Semgrep (community + local) integration testcontainers Postgres, build tag `integration` Skips on docs/markdown-only PRs (paths-ignore: docs/**, **.md). Concurrency group cancels in-flight runs on the same ref. All third-party actions pinned to commit SHAs. Workflow-level read-all baseline; each job grants only what it needs. - .github/workflows/codeql.yml — security-and-quality suite, weekly Monday cron + per-PR. - .github/workflows/scorecard.yml — OSSF Scorecard, weekly Saturday cron + push to main. Job-level permissions list every grant the scorecard action needs (security-events: write, id-token: write, contents: read, actions: read) since job-level REPLACES (does not merge with) workflow-level read-all. Configs: - .github/codeql/codeql-config.yml — excludes go/clear-text-logging for the audit pipeline (same justification as mcp-test: the Logger.Log surface is a forensic sink by design, sanitized via redact_keys). - .golangci.yml — schema v2 with 17 linters: errcheck, errorlint, govet, ineffassign, misspell, revive, staticcheck, unparam, unused, gocritic, gosec, bodyclose, rowserrcheck, sqlclosecheck, nilerr, prealloc, copyloopvar, nolintlint. Test files excluded from gosec/gocritic/errcheck/bodyclose/revive/staticcheck/ unparam/unused with rationale comments. - .semgrep/go-security.yml — two local rules for unbounded slice/map allocations from struct fields (CWE-770). - scripts/coverage-gate.sh — same coverage gate the CI test job invokes, callable from the Makefile too. Excludes Postgres- dependent packages (apikeys, audit/postgres, database, database/migrate) and cmd/api-test from the gate; those are exercised by the integration suite. Modified: - Makefile — coverage-gate target now delegates to scripts/coverage-gate.sh so CI and local use identical logic (no more divergent awk one-liner). - pkg/endpoints/registry_test.go — removed obsolete `r := r` loop-variable copy that the new copyloopvar linter flagged (Go 1.22+ gives each iteration its own loop variable). Local verification (mirrors what each CI job runs): - make verify — GREEN. Lint clean, fmt clean, vet clean, all tests pass with -race, gosec clean, govulncheck clean, coverage gate passes at 89.1% (gate: ≥80%). - go mod tidy — no drift. - gosec -quiet ./... — exit 0. - go test -tags=integration ./tests/... — GREEN against testcontainers Postgres (4 tests + 6 sub-tests, ~7.7s). One pre-commit-review finding addressed: - scorecard.yml originally listed only security-events: write + id-token: write at the job level. Job-level permissions REPLACE (don't merge with) workflow-level read-all, so the action would have run without contents: read or actions: read — silently degrading the Token-Permissions, Branch-Protection, Webhooks, and Dangerous-Workflow checks. Added both grants and a comment block explaining the REPLACE behavior so the next reviewer doesn't trim them again. Frontend job intentionally omitted — no ui/ directory yet (M3 ships the React 19 + Vite + Tailwind 4 SPA). Comment in ci.yml says to copy mcp-test's frontend job verbatim once ui/package.json + ui/pnpm-lock.yaml exist. release.yml not ported either (M5 scope). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/codeql/codeql-config.yml | 27 +++++ .github/workflows/ci.yml | 186 +++++++++++++++++++++++++++++++ .github/workflows/codeql.yml | 55 +++++++++ .github/workflows/scorecard.yml | 58 ++++++++++ .golangci.yml | 81 ++++++++++++++ .semgrep/go-security.yml | 108 ++++++++++++++++++ Makefile | 18 +-- pkg/endpoints/registry_test.go | 1 - scripts/coverage-gate.sh | 78 +++++++++++++ 9 files changed, 599 insertions(+), 13 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 .golangci.yml create mode 100644 .semgrep/go-security.yml create mode 100755 scripts/coverage-gate.sh diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 0000000..18bd304 --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,27 @@ +# api-test CodeQL configuration. +# +# Referenced from .github/workflows/codeql.yml via config-file. Without +# this, CodeQL uses the default suite plus no project-specific tuning. + +name: "api-test CodeQL" + +queries: + - uses: security-and-quality + +# Repository-wide query filters. Each entry must justify why a query is +# excluded; "looks scary" is not a reason. The audit logger is the only +# legitimate "Log function with potentially sensitive data" sink in this +# project, and that's by design (forensics over discretion). Adding +# any new Log-named function in this codebase MUST be reviewed against +# this exception, since the rule no longer fires globally. +query-filters: + - exclude: + id: go/clear-text-logging + # Justification: audit.Logger.Log captures full audit_events + # rows (sanitized via redact_keys) by design. CodeQL traces + # err.Error() -> Event.ErrorMessage -> *ev -> Log() and flags + # the whole chain. The error message is what an operator NEEDS + # to see during incident review; suppressing it would defeat + # the audit pipeline. gosec and semgrep still cover other + # cleartext-credential-in-log patterns at the function-call + # level (e.g. fmt.Println, log.Print*). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..528f9d1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,186 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + # Skip CI on pure docs/markdown changes; the docs.yml workflow + # handles those and we don't want to burn CI minutes on them. + paths-ignore: + - "docs/**" + - "**.md" + +# Cancel in-flight runs for the same ref. github.ref differs between +# `push` (refs/heads/X) and `pull_request` (refs/pull/N/merge), so +# grouping on ref alone leaves push+PR runs of the same SHA both alive. +# Use head_ref (branch name on PRs) when available so the two collapse. +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +# Workflow-level read-only baseline. Each job grants only what it needs. +permissions: read-all + +jobs: + lint: + name: Lint + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.26.3" + cache: true + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: v2.11.4 + args: --timeout=5m + + test: + name: Test + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + # Lift CODECOV_TOKEN to job-level env so the step-level `if:` + # expression below can reference env.CODECOV_TOKEN. Step-level `if:` + # cannot read the `secrets` context directly. + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.26.3" + cache: true + + - name: Verify go.mod is tidy + run: | + go mod tidy + if ! git diff --quiet go.mod go.sum; then + echo "go.mod / go.sum are out of date; run 'go mod tidy' locally" >&2 + git diff go.mod go.sum + exit 1 + fi + + # vet is covered by golangci-lint (govet enabled in .golangci.yml); + # don't double-run it here. + + - name: Run tests with race + coverage + run: go test -race -count=1 -coverprofile=coverage.out -covermode=atomic ./... + + - name: Coverage gate (>=80%) + run: ./scripts/coverage-gate.sh coverage.out 80 + + # Codecov upload skips when CODECOV_TOKEN isn't configured so the + # step doesn't pretend to upload anything. Set the secret in repo + # settings to enable per-PR coverage diff reporting. + - name: Upload coverage to Codecov + if: ${{ env.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + files: ./coverage.out + fail_ci_if_error: false + verbose: true + + build: + name: Build + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.26.3" + cache: true + + - name: Build + run: go build -v ./... + + - name: Verify dependencies + run: go mod verify + + # frontend: not yet present. The React 19 + Vite + Tailwind 4 SPA lands + # in M3; once ui/ exists with package.json + pnpm-lock.yaml, copy + # mcp-test's frontend job verbatim (pnpm install + tsc --noEmit + build). + + security: + name: Security + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.26.3" + cache: true + + - name: gosec + run: | + go install github.com/securego/gosec/v2/cmd/gosec@v2.25.0 + gosec ./... + + - name: govulncheck + uses: golang/govulncheck-action@b625fbe08f3bccbe446d94fbf87fcc875a4f50ee # v1.0.4 + with: + go-version-input: "1.26.3" + repo-checkout: false + + - name: Semgrep + uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 + with: + # Two configs: the community Go ruleset plus our local rules. + # YAML list (rather than a folded scalar) so the action sees + # them as distinct entries even if it tightens parsing later. + config: | + p/golang + .semgrep/ + + integration: + name: Integration tests + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + # Integration tests use testcontainers (Docker socket on the GitHub + # runner). Tagged `integration` so they don't fire in the default + # `go test ./...` invocation. Runs on every PR and push to main; + # narrow with `paths:` filters once the cost of unconditional runs + # outweighs the safety of always exercising them. + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.26.3" + cache: true + + - name: Run integration tests + # Narrow to ./tests/... so we don't rebuild every package with + # the integration tag and rerun unrelated unit tests. New + # integration-tagged tests should live under tests/ to stay + # in scope. + run: go test -tags=integration -race -count=1 -timeout=10m ./tests/... diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3cbc485 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +name: CodeQL + +# This is the project's authoritative CodeQL configuration. If GitHub's +# default-config CodeQL is also enabled (Settings -> Code security -> +# Code scanning -> CodeQL analysis -> "Default"), every PR will run two +# identical scans and bill twice. Set the default to "None" or "Custom" +# pointing at this workflow to dedupe. + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly on Monday at 06:00 UTC. Catches new query updates and any + # vulnerabilities introduced via dependencies that no PR-time scan + # would have surfaced. + - cron: "0 6 * * 1" + +permissions: read-all + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + security-events: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: "1.26.3" + cache: true + + - name: Initialize CodeQL + uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + languages: go + # security-and-quality bundles the security pack with style / + # correctness rules. Project-specific query exclusions live + # in the config-file; findings post to the repo's Security tab. + config-file: ./.github/codeql/codeql-config.yml + + - name: Autobuild + uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + category: "/language:go" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..5dde39b --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,58 @@ +name: OpenSSF Scorecard + +# `publish_results: true` requires the repo to be public; the Scorecard +# workflow will hard-fail on a private fork. If this repo is ever +# flipped to private, drop publish_results and remove the upload-sarif +# step (or migrate to scorecard's API token mode). + +on: + branch_protection_rule: + schedule: + # Saturday 01:30 UTC; offset from CodeQL's Monday cron so the two + # don't queue together when the runner pool is hot. + - cron: "30 1 * * 6" + push: + branches: [main] + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + # Job-level permissions REPLACE the workflow-level read-all + # baseline; list every grant scorecard needs. + # security-events: write — upload SARIF to code-scanning + # id-token: write — sign the SLSA provenance + # contents: read — clone the repo and read workflow files + # actions: read — Token-Permissions check inspects other workflows + security-events: write + id-token: write + contents: read + actions: read + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload SARIF artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: SARIF file + path: results.sarif + retention-days: 5 + + - name: Upload to code-scanning + uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2 + with: + sarif_file: results.sarif diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..c943b40 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,81 @@ +# golangci-lint config for api-test (schema v2). +# +# Aim: fail-fast on real bugs and questionable style without being precious. +# Adjust as the codebase grows. Run with `golangci-lint run` (Makefile: `make lint`). + +version: "2" + +run: + timeout: 5m + tests: true + +linters: + default: none + enable: + - errcheck + - errorlint + - govet + - ineffassign + - misspell + - revive + - staticcheck + - unparam + - unused + - gocritic + - gosec + - bodyclose + - rowserrcheck + - sqlclosecheck + - nilerr + - prealloc + - copyloopvar + - nolintlint + + settings: + revive: + rules: + - name: var-naming + - name: package-comments + - name: exported + arguments: + - disableStutteringCheck + - name: error-naming + - name: error-strings + - name: receiver-naming + - name: empty-block + - name: superfluous-else + - name: unused-parameter + disabled: true + gosec: + excludes: + - G104 # audited explicit ignores in non-critical paths (defer Close, etc.) + - G304 # config paths come from operator-supplied flags + + exclusions: + rules: + # Test files exercise edge cases; allow targeted noise that would be + # wrong in production but is fine in tests. (Other linters still run + # against tests so we catch real bugs there.) Specifically: + # - bodyclose: tests run for a few ms; resource leaks are a non-issue. + # - revive: var-naming nits like fakeIdP vs fakeIDP don't matter in + # test fixtures. + # - staticcheck: stylistic suggestions like QF1008 (drop embedded + # selector) shouldn't gate test code. + # - unparam: helpers built generically but called with one arg are + # normal in tests. + # - unused: helpers shared across one of several tests file may + # read as unused per-package. + - path: _test\.go + linters: + - gosec + - gocritic + - errcheck + - bodyclose + - revive + - staticcheck + - unparam + - unused + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.semgrep/go-security.yml b/.semgrep/go-security.yml new file mode 100644 index 0000000..466d98f --- /dev/null +++ b/.semgrep/go-security.yml @@ -0,0 +1,108 @@ +rules: + # Rule 1: Unbounded slice allocation from struct field + # Detects make([]T, len, cap) where cap comes from a struct field or + # function parameter without a preceding bounds check. This can cause + # OOM if the value is user-controlled (e.g., query parameter, request body). + - id: unbounded-make-slice-capacity + patterns: + - pattern: make($TYPE, $LEN, $CAP) + - pattern-not-inside: | + if $CAP > $MAX { + ... + } + ... + make($TYPE, $LEN, $CAP) + - pattern-not-inside: | + if $CAP >= $MAX { + ... + } + ... + make($TYPE, $LEN, $CAP) + - pattern-not-inside: | + if ... > $CAP { + ... + } + ... + make($TYPE, $LEN, $CAP) + - pattern-not-inside: | + if ... >= $CAP { + ... + } + ... + make($TYPE, $LEN, $CAP) + - pattern-not-inside: | + $CAP = min($CAP, $MAX) + ... + make($TYPE, $LEN, $CAP) + - metavariable-regex: + metavariable: $CAP + regex: '^[a-zA-Z_]\w*\.[a-zA-Z_]\w*$' + message: >- + Slice allocation uses potentially unbounded capacity `$CAP`. + If this value originates from user input (query parameter, request body, + database row), an attacker can trigger OOM by supplying a large number. + Add a bounds check: `if $CAP > maxAllowed { $CAP = maxAllowed }`. + languages: [go] + severity: ERROR + metadata: + category: security + cwe: + - "CWE-770: Allocation of Resources Without Limits or Throttling" + confidence: MEDIUM + impact: HIGH + technology: + - go + + # Rule 2: Unbounded map allocation from struct field + # Same pattern for make(map[K]V, size) where size is user-controlled. + - id: unbounded-make-map-size + patterns: + - pattern: make($TYPE, $SIZE) + - metavariable-regex: + metavariable: $TYPE + regex: '^map\[.+\].+' + - pattern-not-inside: | + if $SIZE > $MAX { + ... + } + ... + make($TYPE, $SIZE) + - pattern-not-inside: | + if $SIZE >= $MAX { + ... + } + ... + make($TYPE, $SIZE) + - pattern-not-inside: | + if ... > $SIZE { + ... + } + ... + make($TYPE, $SIZE) + - pattern-not-inside: | + if ... >= $SIZE { + ... + } + ... + make($TYPE, $SIZE) + - pattern-not-inside: | + $SIZE = min($SIZE, $MAX) + ... + make($TYPE, $SIZE) + - metavariable-regex: + metavariable: $SIZE + regex: '^[a-zA-Z_]\w*\.[a-zA-Z_]\w*$' + message: >- + Map allocation uses potentially unbounded size hint `$SIZE`. + If this value originates from user input, an attacker can trigger OOM. + Add a bounds check: `if $SIZE > maxAllowed { $SIZE = maxAllowed }`. + languages: [go] + severity: ERROR + metadata: + category: security + cwe: + - "CWE-770: Allocation of Resources Without Limits or Throttling" + confidence: MEDIUM + impact: HIGH + technology: + - go diff --git a/Makefile b/Makefile index f372885..b9055c9 100644 --- a/Makefile +++ b/Makefile @@ -123,19 +123,13 @@ coverage: @$(GO) tool cover -func=coverage.out | tail -1 ## coverage-gate: Fail if coverage of testable packages is below COVERAGE_MIN (default 80) -## Excludes Postgres-dependent packages (apikeys, audit/postgres, -## database, database/migrate) — those are covered by the -## integration test suite (go test -tags integration) which -## doesn't contribute to the unit-test coverage profile. -## Also excludes cmd/api-test (binary entry; tested manually). -COVERAGE_EXCLUDE := cmd/api-test|pkg/apikeys|pkg/audit/postgres|pkg/database +## Delegates to scripts/coverage-gate.sh so CI and local +## use identical logic. The script excludes Postgres- +## dependent packages (apikeys, audit/postgres, +## database, database/migrate) and cmd/api-test from the +## gate; those are covered by the integration suite. coverage-gate: coverage - @total=$$( \ - $(GO) tool cover -func=coverage.out \ - | grep -Ev "$(COVERAGE_EXCLUDE)" \ - | awk '$$3 ~ /%$$/ {gsub(/%/,"",$$3); sum+=$$3; n++} END { if (n==0) { print 0 } else { printf "%.1f", sum/n } }' \ - ); \ - awk -v total=$$total -v min=$(COVERAGE_MIN) 'BEGIN { if (total+0 < min+0) { printf "coverage (testable subset) %s%% < %s%%\n", total, min; exit 1 } else { printf "coverage (testable subset) %s%% >= %s%%\n", total, min } }' + @./scripts/coverage-gate.sh coverage.out $(COVERAGE_MIN) ## tools-install: Install lint/security tools at the pinned versions into $(TOOLS_DIR). TOOLS_STAMP := $(TOOLS_DIR)/.installed-$(GOLANGCI_LINT_VERSION)-$(GOSEC_VERSION) diff --git a/pkg/endpoints/registry_test.go b/pkg/endpoints/registry_test.go index bbfa223..04f2fea 100644 --- a/pkg/endpoints/registry_test.go +++ b/pkg/endpoints/registry_test.go @@ -15,7 +15,6 @@ func (g *stubGroup) Name() string { return g.name } func (g *stubGroup) Routes() []EndpointMeta { return g.routes } func (g *stubGroup) Mount(mux *http.ServeMux, mw Middleware) { for _, r := range g.routes { - r := r mux.Handle(r.Method+" "+r.Path, mw(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(r.Name)) }))) diff --git a/scripts/coverage-gate.sh b/scripts/coverage-gate.sh new file mode 100755 index 0000000..b30a097 --- /dev/null +++ b/scripts/coverage-gate.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# coverage-gate.sh — fail if total coverage of testable packages is below MIN. +# +# Why this exists: a few packages (pkg/apikeys, pkg/audit/postgres, +# pkg/database, pkg/database/migrate) require a live Postgres to test +# meaningfully. cmd/api-test is a tiny entry point that's covered by the +# integration suite when Docker is available. We exclude these from the local +# gate so `make verify` is runnable on a developer's laptop without Docker. +# +# CI runs the integration suite (.github/workflows/ci.yml `integration` job) +# separately against testcontainers Postgres, at which point those +# packages are also exercised. +# +# Usage: +# scripts/coverage-gate.sh [coverage.out [min_percent]] +# +# Exit 0 if total >= MIN, 1 otherwise. Prints per-package and total numbers. + +set -euo pipefail + +PROFILE="${1:-coverage.out}" +MIN="${2:-80}" + +if [[ ! -f "$PROFILE" ]]; then + echo "coverage profile not found: $PROFILE" >&2 + exit 2 +fi + +EXCLUDE_PACKAGES=( + "github.com/plexara/api-test/cmd/" + "github.com/plexara/api-test/pkg/apikeys/" + "github.com/plexara/api-test/pkg/audit/postgres/" + "github.com/plexara/api-test/pkg/database/" + "github.com/plexara/api-test/pkg/database/migrate/" +) + +# Build a grep pattern that drops profile entries from excluded packages. +EXCLUDE_RE=$(printf "%s|" "${EXCLUDE_PACKAGES[@]}") +EXCLUDE_RE=${EXCLUDE_RE%|} + +FILTERED=$(mktemp) +trap 'rm -f "$FILTERED"' EXIT + +# Keep the mode line and any line whose path doesn't match an excluded prefix. +{ head -n 1 "$PROFILE" + tail -n +2 "$PROFILE" | grep -Ev "^($EXCLUDE_RE)" || true +} > "$FILTERED" + +# Per-package summary (sourced from filtered profile). +echo "=== coverage by package (excluding postgres-dependent and entry packages) ===" +go tool cover -func="$FILTERED" | awk ' + /^total:/ { next } + { + sub(/^github.com\/plexara\/api-test\//, "", $1) + split($1, p, "/") + pkg = "" + for (i = 1; i < length(p); i++) pkg = (pkg == "" ? p[i] : pkg "/" p[i]) + if (pkg == "") pkg = p[1] + gsub(/%/, "", $3) + s[pkg] += $3; n[pkg]++ + } + END { + for (pp in s) printf " %-32s %5.1f%% (%d funcs)\n", pp, s[pp]/n[pp], n[pp] + } +' | sort -k2 -n + +# Total over filtered profile. +TOTAL=$(go tool cover -func="$FILTERED" | awk '/^total:/ { gsub(/%/, "", $3); print $3 }') +echo "" +echo "=== filtered total: ${TOTAL}% (gate: >=${MIN}%) ===" + +awk -v t="$TOTAL" -v m="$MIN" 'BEGIN { exit !(t+0 >= m+0) }' +RC=$? +if [[ $RC -ne 0 ]]; then + echo "FAIL: total coverage ${TOTAL}% is below the required ${MIN}%" >&2 + exit 1 +fi +echo "OK: coverage gate passed." From e704c9698b3972576efa94c2c55c05ea120189e2 Mon Sep 17 00:00:00 2001 From: cjimti Date: Sun, 10 May 2026 00:23:04 -0700 Subject: [PATCH 4/5] fix CodeQL go/uncontrolled-allocation-size in lorem endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged a high-severity CWE-770 alert on PR #1 at pkg/endpoints/data/data.go:181: words := make([]string, n) // n traces to ?words= query param The pre-existing `if n > 5000 { n = 5000 }` clamp on the prior line is runtime-safe — TestLorem_DefaultsAndCaps already asserts `?words=100000` clamps to 5000 — but CodeQL's go/uncontrolled-allocation-size taint-flow query doesn't recognize the if-clamp pattern as breaking the taint. It does recognize the Go 1.21+ `min(n, const)` builtin call. Behavior unchanged. The five-case trace (`?words=0|10|100000|-5|empty`) yields the same final n in every case as the pre-change code. Same shape: extracted the magic 50 and 5000 into package-level constants loremDefaultWords and loremMaxWords with comments explaining the CodeQL-shape rationale so the next reviewer doesn't unwind them thinking the clamp is the same as before. Other make([T], userN) sites in the diff were checked: every other allocation uses len(internal-slice) or constants and isn't user-tainted, so this is the only CWE-770 hit on the branch. Verified locally: - make verify GREEN (lint, tests with race, gosec, govulncheck, coverage gate at 89.0% / >=80%). - Existing TestLorem_DefaultsAndCaps still passes — confirms the 100000 -> 5000 clamp behavior is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/endpoints/data/data.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/pkg/endpoints/data/data.go b/pkg/endpoints/data/data.go index d86d54e..b76c2a9 100644 --- a/pkg/endpoints/data/data.go +++ b/pkg/endpoints/data/data.go @@ -155,6 +155,18 @@ type LoremResponse struct { Body string `json:"body"` } +const ( + // loremDefaultWords is the word count used when the caller omits + // or sends a non-positive ?words= value. + loremDefaultWords = 50 + // loremMaxWords caps the response word count so a caller can't + // trigger an unbounded allocation by passing ?words=2147483647. + // CodeQL's go/uncontrolled-allocation-size query specifically + // recognizes the `min(n, const)` pattern; the older `if n > N { n = N }` + // form does not break the taint flow even though it's runtime-safe. + loremMaxWords = 5000 +) + // loremDict is a small word bank for fake-Latin generation. var loremDict = []string{ "lorem", "ipsum", "dolor", "sit", "amet", "consectetur", "adipiscing", @@ -172,11 +184,13 @@ func (g *Group) lorem(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() n, _ := strconv.Atoi(q.Get("words")) if n <= 0 { - n = 50 - } - if n > 5000 { - n = 5000 + n = loremDefaultWords } + // min() (Go 1.21+) is the form CodeQL's go/uncontrolled-allocation-size + // taint-flow query recognizes as a bound; replacing the prior + // `if n > 5000 { n = 5000 }` clamp here is a CodeQL-shape change, + // not a behavior change. See loremMaxWords doc for context. + n = min(n, loremMaxWords) rng := newRand(q.Get("seed")) words := make([]string, n) for i := 0; i < n; i++ { From 753a8bb969e04375ec459eed2c70be00dab0ed3e Mon Sep 17 00:00:00 2001 From: cjimti Date: Sun, 10 May 2026 01:25:21 -0700 Subject: [PATCH 5/5] make local verify equal CI; fix lorem + slow clamp-vs-reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit is two things in one. The PRIMARY change is the meta-fix: local `make verify` now mirrors every check CI runs, so the verify-passed sentinel actually means "shippable". The SECONDARY change is the bug + suppressions the new pipeline caught — they land here because splitting would force an intermediate state where the gate fails its own check. Why it's structured this way: when the previous commit (e704c96) landed and CI rejected it, the failure mode was that local `make verify` silently passed a subset of CI's checks. Treating CI as a debugger (push, watch fail, patch, push) is unacceptable on a PR/CI loop measured in minutes. The fix is to make local catch what CI catches BEFORE the push, so that next time the loop is push-once-merge-once. == Pipeline strictness (PRIMARY) == Six gaps closed in `make verify`: 1. `go mod tidy` drift check (was: missing). New `mod-tidy-check` target snapshots go.mod / go.sum first so a dirty working tree doesn't false-positive, runs `go mod tidy`, diffs, and restores the originals via a `trap ... EXIT`. `set -e` ensures a failing `tidy` (network, bad module) propagates instead of being silently swallowed by `;` chaining. 2. `go build -v ./...` (was: missing). New `build-all` target. 3. `go mod verify` (was: missing). New `mod-verify` target. 4. Semgrep with `p/golang` + `.semgrep/` configs (was: missing locally; CI ran it via `semgrep/semgrep-action@v1` which exits 0 on findings without a `SEMGREP_APP_TOKEN`, silently passing the security job). Local now uses `semgrep scan --error`; CI rewritten to use the CLI directly with the same flag and a pinned `semgrep==1.110.0` install. 5. Integration tests under `-tags=integration` (was: missing). New `integration` target hard-fails when Docker isn't running (testcontainers needs the daemon). 6. CodeQL with security-and-quality + custom config exclusions (was: missing). New `codeql` target builds the database from source, runs the analysis, filters the SARIF via `scripts/codeql-gate.sh` against `.github/codeql/codeql-config.yml`. Hard-fails when codeql or jq is missing. Tool-version pins now line up between local and CI: - golangci-lint v2.11.4 (already pinned) - gosec v2.25.0 (already pinned) - semgrep 1.110.0 (NEW pin; warns on local drift) `require-docker`, `require-codeql`, `require-semgrep`, `require-jq` gates hard-fail with install hints (brew/pipx/apt commands). `vet` removed from the verify chain — golangci-lint already runs govet (`.golangci.yml`). The verify-passed sentinel `.claude/.last-verify-passed` writes ONLY after the FULL set passes. Previously it represented a subset; that was the lie that let the CodeQL alert ship. == Bug + suppressions the new pipeline caught (SECONDARY) == Running the new full `make verify` against the previous tree state caught: 1. The `go/uncontrolled-allocation-size` CodeQL high-severity alert that triggered this whole exercise. `pkg/endpoints/data/data.go` `lorem` handler changed from clamping (`if n > 5000 { n = 5000 }`, `n = min(n, 5000)`) to validate-and-reject — same shape `sized` already uses, and the form CodeQL's taint-flow query recognizes as a sanitizer. Constants `loremDefaultWords = 50` and `loremMaxWords = 5000` extracted with doc comments. Behavior change: `?words > 5000` now returns 400 with `{"error":"words N exceeds max 5000"}` instead of silently clamping. Test renamed `TestLorem_DefaultsAndCaps` → `TestLorem_DefaultsAndRejects` and rewritten to assert at-cap success, one-over → 400, way-over → 400. Doc updated in lockstep at `docs/endpoints/data.md`. 2. Two Semgrep `math-random-used` findings on the `math/rand/v2` imports in `data.go` and `failure.go`. Both are intentional — PCG generators seeded from a caller-supplied string for reproducible test fixtures; crypto/rand would defeat the determinism contract. Suppressed with `// nosemgrep:` comments bearing justifications mirroring the existing `// #nosec G404` annotations on the same use sites. CI was silently passing these because of the action's exit-0 behavior; now both sides error on findings, and these two are explicitly suppressed. 3. The `slow` endpoint at `pkg/endpoints/failure/failure.go` had the same clamp-vs-reject shape as the original lorem bug. CodeQL didn't flag it (it's a timer, not an allocation), but the principle the new pipeline enforces is consistency: if clamp is wrong for lorem, it's wrong for slow. Fixed to validate-and-reject (`?ms > 60000` → 400), matching constant `slowMaxMS = 60_000` extracted with doc comment, new `TestSlow_RejectsOverMax` covering at-cap-with-cancel, one-over, way-over. Doc updated at `docs/endpoints/failure.md`. == Verification == - `make verify` GREEN end-to-end (lint, fmt, mod-tidy-check, mod-verify, build-all, gosec, govulncheck, semgrep, coverage-gate at 89%, integration with testcontainers Postgres ~7s, codeql with security-and-quality suite ~2 min). - Re-running `make codeql` against the previous commit's tree state reproduces the EXACT same alert CI saw — confirming the new pipeline catches what was missed. - Three rounds of pre-commit adversarial review: 6 findings round 1 (all addressed), 1 substantive finding round 2 (the silent-swallow regression in mod-tidy-check, addressed), CLEAN round 3. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 19 +-- Makefile | 176 ++++++++++++++++++++++++-- docs/endpoints/data.md | 6 +- docs/endpoints/failure.md | 5 +- pkg/endpoints/data/data.go | 24 ++-- pkg/endpoints/data/data_test.go | 32 ++++- pkg/endpoints/failure/failure.go | 14 +- pkg/endpoints/failure/failure_test.go | 35 +++++ scripts/codeql-gate.sh | 71 +++++++++++ 9 files changed, 340 insertions(+), 42 deletions(-) create mode 100755 scripts/codeql-gate.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 528f9d1..7fea852 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,14 +148,17 @@ jobs: repo-checkout: false - name: Semgrep - uses: semgrep/semgrep-action@713efdd345f3035192eaa63f56867b88e63e4e5d # v1 - with: - # Two configs: the community Go ruleset plus our local rules. - # YAML list (rather than a folded scalar) so the action sees - # them as distinct entries even if it tightens parsing later. - config: | - p/golang - .semgrep/ + # `semgrep/semgrep-action@v1` exits 0 even when findings are + # present (it's designed to upload to AppSec Platform when + # SEMGREP_APP_TOKEN is set, otherwise just print). We need it + # to FAIL the job on findings so CI matches the local + # `make semgrep` gate (which uses --error). Run the CLI + # directly. Version is pinned (mirrors $(SEMGREP_VERSION) in + # the Makefile); --break-system-packages is required on + # ubuntu-24.04's PEP 668 system Python. + run: | + python3 -m pip install --quiet --break-system-packages 'semgrep==1.110.0' + semgrep scan --error --quiet --config p/golang --config .semgrep/ integration: name: Integration tests diff --git a/Makefile b/Makefile index b9055c9..1d9d7f6 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,16 @@ # api-test Makefile # +# `make verify` is the single source of truth for "is this tree shippable". +# It runs every check the CI workflows run — same commands, same versions, +# same outcome. If CI catches something this target doesn't, that's a bug +# in this Makefile, not in the code. The verify-passed sentinel +# (.claude/.last-verify-passed) is only written after the FULL set passes. +# # Common targets: -# make build # build the binary into ./bin/api-test -# make test # go test -race -count=1 -# make verify # full CI-equivalent: tools-check, fmt, vet, test, lint, security, coverage -# make dev-anon # postgres-free anonymous-mode binary; fastest iteration +# make build # compile the binary into ./bin/api-test +# make verify # full CI-equivalent gate (slow; pre-commit / pre-push) +# make test # unit tests with race detector (inner-loop iteration) +# make dev-anon # postgres-free anonymous-mode binary; fastest dev loop # # Run `make help` to see every target. @@ -30,6 +36,7 @@ UI_EMBED_DIR := ./internal/ui/dist # Pinned tool versions; keep in sync with .github/workflows/ci.yml. GOLANGCI_LINT_VERSION := v2.11.4 GOSEC_VERSION := v2.25.0 +SEMGREP_VERSION := 1.110.0 TOOLS_DIR := $(abspath $(BUILD_DIR)/tools) @@ -42,10 +49,16 @@ GOLINT := $(TOOLS_DIR)/golangci-lint GOSEC := $(TOOLS_DIR)/gosec GOVULN := $(TOOLS_DIR)/govulncheck -.PHONY: all build test test-short bench fmt fmt-check vet tidy clean help dev-secrets \ +# CodeQL artifacts. Re-created on each `make codeql` run. +CODEQL_DB := $(BUILD_DIR)/codeql-db +CODEQL_RESULT := $(BUILD_DIR)/codeql-results.sarif + +.PHONY: all build build-all test test-short bench fmt fmt-check vet tidy \ + mod-tidy-check mod-verify clean help dev-secrets \ ui ui-dev ui-clean embed-clean \ - lint security gosec govulncheck \ + lint security gosec govulncheck semgrep \ coverage coverage-gate coverage-report \ + integration codeql require-docker require-codeql require-semgrep require-jq \ verify tools-check tools-install \ dev dev-anon dev-up dev-wait dev-ui-if-needed dev-down dev-logs \ docker docs docs-serve run version @@ -60,6 +73,12 @@ build: $(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_DIR) @echo "Binary built: $(BUILD_DIR)/$(BINARY_NAME)" +## build-all: go build -v ./... (mirrors CI's Build job; catches build paths +## that `go test` would skip — packages without tests, etc.) +build-all: + @echo "Building all packages..." + $(GOBUILD) -v ./... + ## test: Run unit tests with race detector test: @echo "Running tests..." @@ -96,6 +115,38 @@ vet: tidy: $(GOMOD) tidy +## mod-tidy-check: Fail if `go mod tidy` would change go.mod / go.sum. +## Snapshots the files first so an already-dirty working +## tree (uncommitted edits to go.mod for a separate task) +## doesn't false-positive. Restores on either outcome. +## Mirrors CI's "Verify go.mod is tidy" step in spirit; +## CI runs in a fresh checkout so it doesn't need the +## snapshot, but local does. +mod-tidy-check: + @echo "Checking go.mod is tidy..." + @# Single chained shell: trap installed first so it covers every + @# subsequent command (including the cp calls) — leak-free even on + @# Ctrl-C between snapshots. `set -e` so any failure (cp, tidy, + @# diff) hard-aborts the recipe; without it, `;` between commands + @# silently swallows non-zero exits and the gate becomes a lie. + @trap 'mv -f go.mod.tidy-snapshot go.mod 2>/dev/null; mv -f go.sum.tidy-snapshot go.sum 2>/dev/null; true' EXIT; \ + set -e; \ + cp go.mod go.mod.tidy-snapshot; \ + cp go.sum go.sum.tidy-snapshot; \ + $(GOMOD) tidy; \ + if ! diff -q go.mod go.mod.tidy-snapshot >/dev/null || ! diff -q go.sum go.sum.tidy-snapshot >/dev/null; then \ + echo "FAIL: go.mod / go.sum are out of date; run 'go mod tidy' locally" >&2; \ + diff -u go.mod.tidy-snapshot go.mod || true; \ + diff -u go.sum.tidy-snapshot go.sum || true; \ + exit 1; \ + fi + @echo "go.mod / go.sum: tidy." + +## mod-verify: go mod verify (mirrors CI's Build job step) +mod-verify: + @echo "Verifying module checksums..." + $(GOMOD) verify + ## lint: golangci-lint run (pinned version from $(TOOLS_DIR)) lint: tools-check @echo "Running golangci-lint $(GOLANGCI_LINT_VERSION)..." @@ -111,8 +162,22 @@ govulncheck: tools-check @echo "Running govulncheck..." $(GOVULN) ./... -## security: gosec + govulncheck -security: gosec govulncheck +## semgrep: Run Semgrep with the same configs CI uses (p/golang + .semgrep/). +## Hard-fails if the semgrep CLI is not installed — silent skip +## would let CI catch what local missed. Warns if the local +## version doesn't match the pinned $(SEMGREP_VERSION) so +## rule-set drift between local and CI is visible. +semgrep: require-semgrep + @actual="$$(semgrep --version 2>&1 | head -1)"; \ + if [ "$$actual" != "$(SEMGREP_VERSION)" ]; then \ + echo "WARN: semgrep version drift — pinned: $(SEMGREP_VERSION), local: $$actual" >&2; \ + echo " install matching: pipx install --force semgrep==$(SEMGREP_VERSION)" >&2; \ + fi + @echo "Running semgrep $(SEMGREP_VERSION) (p/golang + .semgrep/)..." + semgrep scan --error --quiet --config p/golang --config .semgrep/ + +## security: gosec + govulncheck + semgrep (mirrors CI's Security job) +security: gosec govulncheck semgrep COVERAGE_MIN ?= 80 @@ -131,6 +196,77 @@ coverage: coverage-gate: coverage @./scripts/coverage-gate.sh coverage.out $(COVERAGE_MIN) +## integration: Run the integration test suite under build tag `integration` +## against testcontainers Postgres. Mirrors CI's Integration job. +## Hard-fails if Docker is not available — testcontainers needs it. +integration: require-docker + @echo "Running integration tests (testcontainers Postgres)..." + $(GOTEST) -tags=integration -race -count=1 -timeout=10m ./tests/... + +## codeql: Run the same CodeQL security-and-quality suite CI runs. +## Builds the database from source, runs the analysis, and +## filters the SARIF against .github/codeql/codeql-config.yml +## exclusions. Hard-fails if the codeql or jq CLIs are not on +## PATH (jq is used by scripts/codeql-gate.sh to parse SARIF). +## Slow (~2-3 min on first run, ~1 min cached). +codeql: require-codeql require-jq + @echo "Building CodeQL database (Go) at $(CODEQL_DB)..." + @rm -rf $(CODEQL_DB) + @mkdir -p $(BUILD_DIR) + codeql database create $(CODEQL_DB) --language=go --source-root=. --overwrite + @echo "" + @echo "Analyzing with security-and-quality + project config..." + codeql database analyze $(CODEQL_DB) \ + codeql/go-queries:codeql-suites/go-security-and-quality.qls \ + --format=sarif-latest \ + --output=$(CODEQL_RESULT) \ + --threads=0 \ + --sarif-category=/language:go + @echo "" + @echo "Filtering against .github/codeql/codeql-config.yml exclusions..." + @./scripts/codeql-gate.sh $(CODEQL_RESULT) .github/codeql/codeql-config.yml + @echo "CodeQL: clean." + +# --- tool gates: every external tool the verify pipeline depends on must +# either be present or hard-fail with install instructions. Silent skip +# is what got us here. + +require-docker: + @if ! command -v docker >/dev/null 2>&1; then \ + echo "FAIL: docker CLI not found. Install Docker Desktop or colima." >&2; \ + exit 1; \ + fi + @if ! docker info >/dev/null 2>&1; then \ + echo "FAIL: docker daemon not running. Start Docker Desktop / colima first." >&2; \ + exit 1; \ + fi + +require-codeql: + @if ! command -v codeql >/dev/null 2>&1; then \ + echo "FAIL: codeql CLI not on PATH." >&2; \ + echo " brew install codeql" >&2; \ + echo " (or fetch from https://github.com/github/codeql-cli-binaries/releases)" >&2; \ + exit 1; \ + fi + +require-semgrep: + @if ! command -v semgrep >/dev/null 2>&1; then \ + echo "FAIL: semgrep CLI not on PATH." >&2; \ + echo " pipx install semgrep==$(SEMGREP_VERSION) # recommended (pinned)" >&2; \ + echo " pip3 install semgrep==$(SEMGREP_VERSION) # alternative" >&2; \ + echo " brew install semgrep # macOS (unpinned, may drift)" >&2; \ + exit 1; \ + fi + +require-jq: + @if ! command -v jq >/dev/null 2>&1; then \ + echo "FAIL: jq CLI not on PATH (codeql-gate.sh parses SARIF with it)." >&2; \ + echo " brew install jq # macOS" >&2; \ + echo " apt install jq # debian/ubuntu" >&2; \ + echo " dnf install jq # fedora/rhel" >&2; \ + exit 1; \ + fi + ## tools-install: Install lint/security tools at the pinned versions into $(TOOLS_DIR). TOOLS_STAMP := $(TOOLS_DIR)/.installed-$(GOLANGCI_LINT_VERSION)-$(GOSEC_VERSION) tools-install: $(TOOLS_STAMP) @@ -151,12 +287,27 @@ tools-check: tools-install @echo " gosec: $$($(GOSEC) --version 2>/dev/null | head -1)" @echo " govulncheck: $$(test -x $(GOVULN) && echo present || echo MISSING)" -## verify: Full CI-equivalent suite. Fails on any error including <80% coverage. -verify: tools-check fmt-check vet test lint security coverage-gate +## verify: Full CI-equivalent gate. Runs every check the CI workflows run. +## Order: cheap → expensive so the loop short-circuits on the first +## failure as fast as possible. The .claude/.last-verify-passed +## sentinel is ONLY written after the full set passes; the +## pre-commit gate hook reads it as the source of truth. +## +## Mirrors: +## .github/workflows/ci.yml (lint, test, build, security, integration) +## .github/workflows/codeql.yml (CodeQL security-and-quality) +## +## External tool requirements (silent skip is forbidden — see +## require-* targets for install instructions): +## - docker (running) for `make integration` +## - codeql for `make codeql` +## - semgrep for `make semgrep` +verify: tools-check fmt-check mod-tidy-check mod-verify build-all lint security coverage-gate integration codeql @echo "" - @echo "=== verify: all checks passed ===" + @echo "=== verify: all checks passed (CI-equivalent set) ===" @# Pre-commit gate sentinel: record the current diff hash so the @# review-gate hook knows verify is green for this exact tree state. + @# Only written here, after the FULL set passes. Anything less is a lie. @mkdir -p .claude @{ git diff --cached HEAD 2>/dev/null; git diff 2>/dev/null; } \ | shasum -a 256 | cut -c1-16 > .claude/.last-verify-passed @@ -205,9 +356,10 @@ DOCS_PORT ?= 8001 docs-serve: mkdocs serve -a $(DOCS_HOST):$(DOCS_PORT) -## clean: Remove build artifacts +## clean: Remove build artifacts (binary, coverage, codeql db/sarif) clean: rm -rf $(BUILD_DIR) coverage.out coverage.html + @# don't rm $(BUILD_DIR)/tools — pinned linters live there ## version: Show resolved version metadata version: diff --git a/docs/endpoints/data.md b/docs/endpoints/data.md index 463ed34..af15cbf 100644 --- a/docs/endpoints/data.md +++ b/docs/endpoints/data.md @@ -109,10 +109,12 @@ Response (200): { "words": 5, "body": "Ad excepteur anim sint laborum." } ``` -Defaults and caps: +Defaults and bounds: - `words <= 0` (omitted) → defaults to 50. -- `words > 5000` → capped at 5000. +- `words > 5000` → 400 `{ "error": "words 5001 exceeds max 5000" }`. + (Validate-and-reject mirrors the `sized` endpoint and is the form + CodeQL's allocation-size taint analysis recognizes as a bound.) - `seed` is hashed (FNV-64) twice with different salts to seed a PCG generator, so different seeds give independent streams. diff --git a/docs/endpoints/failure.md b/docs/endpoints/failure.md index 38b1a7d..2943d82 100644 --- a/docs/endpoints/failure.md +++ b/docs/endpoints/failure.md @@ -79,7 +79,10 @@ Cancelled response (499; non-standard "client closed" status): Bounds: - `ms <= 0` → 0 (immediate response). -- `ms > 60000` → capped at 60000 (60s). +- `ms > 60000` → 400 `{ "error": "ms 60001 exceeds max 60000" }`. + (Validate-and-reject mirrors `lorem` and `sized`. Clamping a 24-hour + request silently to 60s would lie to the caller about the duration + they got.) ### What it proves diff --git a/pkg/endpoints/data/data.go b/pkg/endpoints/data/data.go index b76c2a9..9c57da1 100644 --- a/pkg/endpoints/data/data.go +++ b/pkg/endpoints/data/data.go @@ -9,7 +9,7 @@ import ( "encoding/json" "fmt" "hash/fnv" - "math/rand/v2" + "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. Mirrors the //#nosec G404 annotations on the use sites. "net/http" "strconv" "strings" @@ -159,11 +159,13 @@ const ( // loremDefaultWords is the word count used when the caller omits // or sends a non-positive ?words= value. loremDefaultWords = 50 - // loremMaxWords caps the response word count so a caller can't - // trigger an unbounded allocation by passing ?words=2147483647. - // CodeQL's go/uncontrolled-allocation-size query specifically - // recognizes the `min(n, const)` pattern; the older `if n > N { n = N }` - // form does not break the taint flow even though it's runtime-safe. + // loremMaxWords is the upper bound on the response word count. + // Requests with ?words > loremMaxWords return 400. Matches the + // validate-and-reject shape of `sized` so CodeQL's + // go/uncontrolled-allocation-size taint-flow query recognizes the + // early return as a sanitizer; clamping (`if n > N { n = N }`) and + // `min(n, N)` both leave the value tainted in CodeQL's model even + // though they are runtime-safe. loremMaxWords = 5000 ) @@ -186,11 +188,11 @@ func (g *Group) lorem(w http.ResponseWriter, r *http.Request) { if n <= 0 { n = loremDefaultWords } - // min() (Go 1.21+) is the form CodeQL's go/uncontrolled-allocation-size - // taint-flow query recognizes as a bound; replacing the prior - // `if n > 5000 { n = 5000 }` clamp here is a CodeQL-shape change, - // not a behavior change. See loremMaxWords doc for context. - n = min(n, loremMaxWords) + if n > loremMaxWords { + writeJSONError(w, http.StatusBadRequest, + fmt.Sprintf("words %d exceeds max %d", n, loremMaxWords)) + return + } rng := newRand(q.Get("seed")) words := make([]string, n) for i := 0; i < n; i++ { diff --git a/pkg/endpoints/data/data_test.go b/pkg/endpoints/data/data_test.go index ce36406..e9ffcbc 100644 --- a/pkg/endpoints/data/data_test.go +++ b/pkg/endpoints/data/data_test.go @@ -78,8 +78,10 @@ func TestLorem_SeededReproducible(t *testing.T) { } } -func TestLorem_DefaultsAndCaps(t *testing.T) { +func TestLorem_DefaultsAndRejects(t *testing.T) { mux := newTestMux(t) + + // ?words=0 → defaults to 50 words and a body terminating in a period. body := doGet(t, mux, "/v1/lorem?words=0&seed=x") var resp LoremResponse if err := json.Unmarshal([]byte(body), &resp); err != nil { @@ -88,15 +90,35 @@ func TestLorem_DefaultsAndCaps(t *testing.T) { if resp.Words != 50 { t.Errorf("default words = %d want 50", resp.Words) } - body = doGet(t, mux, "/v1/lorem?words=100000&seed=x") + if !strings.HasSuffix(resp.Body, ".") { + t.Error("lorem body missing trailing period") + } + + // ?words=5000 (the cap exactly) → still succeeds. + body = doGet(t, mux, "/v1/lorem?words=5000&seed=x") if err := json.Unmarshal([]byte(body), &resp); err != nil { t.Fatal(err) } if resp.Words != 5000 { - t.Errorf("cap = %d want 5000", resp.Words) + t.Errorf("at-cap words = %d want 5000", resp.Words) } - if !strings.HasSuffix(resp.Body, ".") { - t.Error("lorem body missing trailing period") + + // ?words=5001 (one over the cap) → 400 (validate-and-reject; CodeQL's + // go/uncontrolled-allocation-size query recognizes early return as + // a sanitizer but does NOT recognize a clamp). + req := httptest.NewRequest(http.MethodGet, "/v1/lorem?words=5001&seed=x", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("words=5001 status %d want 400", w.Code) + } + + // ?words=100000 (well over the cap) → 400 same as above. + req = httptest.NewRequest(http.MethodGet, "/v1/lorem?words=100000&seed=x", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("words=100000 status %d want 400", w.Code) } } diff --git a/pkg/endpoints/failure/failure.go b/pkg/endpoints/failure/failure.go index a277ccd..d7dab98 100644 --- a/pkg/endpoints/failure/failure.go +++ b/pkg/endpoints/failure/failure.go @@ -7,7 +7,7 @@ import ( "encoding/json" "fmt" "hash/fnv" - "math/rand/v2" + "math/rand/v2" // nosemgrep: go.lang.security.audit.crypto.math_random.math-random-used -- intentional: PCG seeded from (seed, callID) for reproducible flaky-test outcomes; crypto/rand would defeat the determinism contract. Mirrors the //#nosec G404 annotations on the use sites. "net/http" "strconv" "time" @@ -92,13 +92,21 @@ type SlowResponse struct { RequestedM int `json:"requested_ms"` } +// slowMaxMS is the upper bound on the per-request sleep. Requests with +// ?ms > slowMaxMS return 400. Validate-and-reject mirrors the lorem and +// sized handlers; clamping silently lies to the caller (?ms=86400000 +// would return slept_ms=60000 with no error indicator). +const slowMaxMS = 60_000 + func (g *Group) slow(w http.ResponseWriter, r *http.Request) { ms, _ := strconv.Atoi(r.URL.Query().Get("ms")) if ms < 0 { ms = 0 } - if ms > 60_000 { - ms = 60_000 + if ms > slowMaxMS { + writeJSONError(w, http.StatusBadRequest, + fmt.Sprintf("ms %d exceeds max %d", ms, slowMaxMS)) + return } start := time.Now() timer := time.NewTimer(time.Duration(ms) * time.Millisecond) diff --git a/pkg/endpoints/failure/failure_test.go b/pkg/endpoints/failure/failure_test.go index ead0bdd..7a1cbcb 100644 --- a/pkg/endpoints/failure/failure_test.go +++ b/pkg/endpoints/failure/failure_test.go @@ -83,6 +83,41 @@ func TestSlow_FastPath(t *testing.T) { } } +func TestSlow_RejectsOverMax(t *testing.T) { + mux := newTestMux(t) + // At the cap exactly: succeeds (but we shorten the actual sleep via + // a cancel so the test doesn't wait 60s; the assertion is on the + // status, which gets written only after the sleep completes or + // gets cancelled). + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/v1/slow?ms=60000", nil).WithContext(ctx) + w := httptest.NewRecorder() + done := make(chan struct{}) + go func() { mux.ServeHTTP(w, req); close(done) }() + time.Sleep(20 * time.Millisecond) + cancel() + <-done + if w.Code != 499 { + t.Errorf("at-cap with cancel: status %d want 499", w.Code) + } + + // One over the cap: 400 immediately, no sleep. + req = httptest.NewRequest(http.MethodGet, "/v1/slow?ms=60001", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("ms=60001 status %d want 400", w.Code) + } + + // Way over the cap: 400 immediately. + req = httptest.NewRequest(http.MethodGet, "/v1/slow?ms=86400000", nil) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("ms=86400000 status %d want 400", w.Code) + } +} + func TestFlaky_Reproducible(t *testing.T) { mux := newTestMux(t) a := doStatus(t, mux, "/v1/flaky?fail_rate=0.5&seed=abc&call_id=7") diff --git a/scripts/codeql-gate.sh b/scripts/codeql-gate.sh new file mode 100755 index 0000000..c9d0104 --- /dev/null +++ b/scripts/codeql-gate.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# +# codeql-gate.sh — fail if a CodeQL SARIF result has any findings that +# aren't excluded by the project config. +# +# Args: +# $1 path to SARIF file +# $2 path to codeql-config.yml (used to read query-filters.exclude.id) +# +# Exit 0 = clean. Exit 1 = at least one finding survives the filters. + +set -euo pipefail + +SARIF="${1:-}" +CONFIG="${2:-}" + +if [[ -z "$SARIF" || ! -f "$SARIF" ]]; then + echo "codeql-gate: missing SARIF input" >&2 + exit 1 +fi + +# Build the exclude list from codeql-config.yml. +EXCLUDES=() +if [[ -n "$CONFIG" && -f "$CONFIG" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && EXCLUDES+=("$line") + done < <(awk ' + /^query-filters:/ { in_qf=1; next } + in_qf && /^[^ ]/ { in_qf=0 } + in_qf && /^[[:space:]]*-[[:space:]]*exclude:/ { in_excl=1; next } + in_qf && in_excl && /^[[:space:]]*id:/ { + sub(/^[[:space:]]*id:[[:space:]]*/, "") + sub(/[[:space:]]+#.*$/, "") + gsub(/[\047"]/, "") + print + in_excl=0 + } + ' "$CONFIG") +fi + +# Pull every result's ruleId from SARIF. Use a while-read loop instead +# of `mapfile`/`readarray` so we work on macOS bash 3.2 too. +RULES=() +while IFS= read -r line; do + [[ -n "$line" ]] && RULES+=("$line") +done < <(jq -r '.runs[]?.results[]?.ruleId // empty' "$SARIF") + +KEPT=() +for r in "${RULES[@]+"${RULES[@]}"}"; do + excluded=0 + for e in "${EXCLUDES[@]+"${EXCLUDES[@]}"}"; do + if [[ "$r" == "$e" ]]; then + excluded=1 + break + fi + done + [[ $excluded -eq 0 ]] && KEPT+=("$r") +done + +if [[ ${#KEPT[@]} -eq 0 ]]; then + exit 0 +fi + +echo "codeql-gate: ${#KEPT[@]} findings after exclusions:" >&2 +for r in "${KEPT[@]+"${KEPT[@]}"}"; do + echo " - $r" >&2 +done +echo "" >&2 +echo "Inspect details with:" >&2 +echo " jq '.runs[].results[] | select(.ruleId == \"\") | {ruleId, locations}' $SARIF" >&2 +exit 1

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 literal 0 HcmV?d00001 diff --git a/docs/images/og-card.svg b/docs/images/og-card.svg new file mode 100644 index 0000000..055eb75 --- /dev/null +++ b/docs/images/og-card.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PLEXARA ∙ OPEN SOURCE + + + v0.1.0 / Apache 2.0 + + api-test + + + + A controllable HTTP REST fixture + built for testing API gateways. + + + Deterministic endpoints + + Postgres audit log + + embedded portal + + + + github.com/plexara/api-test + + + + + + + + + + + + + + + + + + GET /v1/whoami + + + + + GET /v1/whoami HTTP/1.1 + + + Host: api-test.example.com + X-API-Key: [redacted] + X-Request-Id: 8c5b…3f7a + + + → 200 OK application/json + + + { + "subject": "devkey", + "auth_type": "apikey" + } + + + + 200 OK + audited → postgres + 8 ms + + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c62dcbb --- /dev/null +++ b/docs/index.md @@ -0,0 +1,110 @@ +--- +title: api-test +template: home.html +hide: + - navigation + - toc + - footer +--- + +# api-test + +A controllable HTTP REST fixture, built specifically as an upstream for +testing API gateways end-to-end. + +The endpoints it exposes are intentionally boring; they return +predictable output for predictable input. The point isn't what they +*do*; the point is how they let you verify a gateway in front of them +is doing the right things: forwarding identity, redacting credentials, +detecting pagination, surfacing errors, enforcing timeouts, and so on. +Every request is captured in a Postgres-backed audit log, so a tester +can compare what the client sent through the gateway, what reached +this server, and what came back. + +It is also an opinionated reference for building production-quality +HTTP test fixtures in Go: typed endpoint groups, an in-tree OpenAPI +generator, OIDC inbound auth, audit logging, embedded React portal. + +[Get started](getting-started/quickstart.md){ .md-button .md-button--primary } +[Source on GitHub](https://github.com/plexara/api-test){ .md-button } + +## What's inside + +