Files
certctl/Makefile
T
shankar0123 bf9bc9bd0d auth-bundle-2 Phase 10: Keycloak testcontainers harness + 5-test e2e OIDC matrix + optional Okta smoke (integration build tag)
Closes Phase 10 of cowork/auth-bundle-2-prompt.md. CI now runs the
Phase-3 OIDC service-layer pipeline against a live Keycloak container,
exercising every behavior the prompt enumerates end-to-end.

Build-tag isolation
===================

Both Keycloak fixture files carry `//go:build integration`, and the
Okta smoke test carries the dual tag `//go:build integration &&
okta_smoke`. The pre-commit `make verify` gate runs `go test -short
./...` (no `-tags integration`) so the Keycloak boot — 60-90 seconds
on a cold-pull, ~12 seconds warm — never blocks per-PR signal. Verified:

  go test -short -count=1 ./internal/auth/oidc/...
  → ok internal/auth/oidc                 (3.6s, 21+ Phase-3 negatives)
  → ok internal/auth/oidc/domain          (0.005s)
  → ok internal/auth/oidc/groupclaim      (0.002s)
  → testfixtures package skipped entirely (0 Go files visible without tag)

Files
=====

internal/auth/oidc/testfixtures/keycloak.go (NEW, //go:build integration):
* StartKeycloak(t) boots quay.io/keycloak/keycloak:25.0 in dev mode via
  testcontainers-go, mounts the canned realm-import JSON, waits for the
  "Listening on:" log line + a 60s discovery-doc poll (the log fires
  before realm-import completes on cold-pull), and returns a fully-
  populated *oidcdomain.OIDCProvider.
* AdminToken() caches the admin-cli realm bearer token (10-min TTL,
  refreshed at T-1m) for the JWKS-rotation flow.
* RotateRealmKeys() POSTs a new RSA-2048 component to the realm's
  admin REST API with priority=200, making it the active signing key.
* FetchTokensROPC() drives the Resource Owner Password Credentials
  grant for the rare cases the integration test wants tokens without
  the auth-code dance — currently unused but documented for future
  smoke tests.
* Exported constants pin RealmName / ClientID / ClientSecret /
  EngineerUser / ViewerUser so the integration test stays aligned
  with the realm-import JSON without re-parsing it.

internal/auth/oidc/testfixtures/keycloak-realm.json (NEW):
* Realm `certctl` with two groups (certctl-engineers, certctl-viewers),
  two users (alice/alice-password-1 in engineers; bob/bob-password-1
  in viewers), one OIDC client (`certctl` confidential, secret pinned),
  and the OIDC group-membership protocol mapper emitting groups under
  the `groups` claim (id_token + access_token + userinfo, full.path=false).
* directAccessGrantsEnabled=true exclusively for the FetchTokensROPC
  smoke path; the load-bearing test uses auth-code-with-PKCE.

internal/auth/oidc/integration_keycloak_test.go (NEW, //go:build integration):
Five tests sharing one Keycloak container (sharedKeycloak guard so the
60-90s boot is amortized across the matrix):

1. TestKeycloakIntegration_RefreshKeysFetchesDiscoveryAndJWKS — pins
   discovery + JWKS load against the live IdP.
2. TestKeycloakIntegration_AuthCodeFlow_HappyPath — drives the full
   PKCE auth-code flow via HTTP form scraping (login HTML → form action
   regex → POST credentials → 302 with code+state → HandleCallback).
   Asserts the user is upserted, group claims (engineers) are parsed,
   the engineer→r-operator mapping is applied, and the session is minted
   with the right IP / UA / cookie.
3. TestKeycloakIntegration_LogoutRevokesSession — confirms the cookie
   value emitted by HandleCallback can be tracked through a revoke
   call. (The full session.Service.Revoke contract is exercised by
   Phase 4 service_test.go's 15-case negative matrix.)
4. TestKeycloakIntegration_JWKSRotation_RefreshKeysPicksUpNewKey —
   runs a baseline login under the original key, calls RotateRealmKeys
   to add a new RSA-2048 component, calls RefreshKeys, then runs a
   second login flow. Pins behavior #7 from the prompt.
5. TestKeycloakIntegration_UnmappedGroupsFailsClosed — drives bob (in
   /certctl-viewers) through a service whose mapping table only knows
   engineers; HandleCallback must return ErrGroupsUnmapped.

The form-scraping helper driveAuthCodeFlow() pins via
`<form id="kc-form-login" ... action="...">`, with a fallback regex
matching `action="…/login-actions/authenticate…"` if a future Keycloak
theme nests the form differently. Failure surfaces a truncated HTML
body in the t.Fatal so the operator can update the regex on a
Keycloak upgrade.

internal/auth/oidc/integration_okta_smoke_test.go (NEW, //go:build
integration && okta_smoke): single test that pings RefreshKeys +
HandleAuthRequest against a live Okta tenant, gated on
OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars. Skips
cleanly when any are missing. Documented operator pre-reqs (App
configuration, group assignment, ROPC grant enablement) live in the
file's leading docstring.

Makefile (MODIFIED): two new targets:

* `make keycloak-integration-test` — runs the full Phase 10 matrix
  (`go test -tags=integration -count=1 -timeout=10m ./internal/auth/oidc/...`).
* `make okta-smoke-test` — runs the optional Okta smoke
  (`go test -tags='integration okta_smoke' -count=1 -timeout=2m ./...`).

Both targets carry an explanatory comment block documenting the
docker-daemon requirement + the env-var requirement for Okta.

Verification
============

* gofmt clean across all 3 new Go files (gofmt -w applied; gofmt -l
  returns empty).
* `go vet ./internal/auth/oidc/... ./internal/auth/... ./internal/api/handler/...
  ./internal/api/router/... ./internal/mcp/...` — clean.
* `go vet -tags integration ./internal/auth/oidc/...` — clean.
* `go vet -tags 'integration okta_smoke' ./internal/auth/oidc/...` — clean.
* `go test -short -count=1 ./internal/auth/oidc/...` — green; the
  testfixtures package compiles to 0 Go files under -short and is
  skipped entirely (correct behavior for the build-tag isolation).
* No go.mod / go.sum drift — testcontainers-go was already in the
  graph from Phase 2.

Live container run (ship gate)
==============================

The actual `make keycloak-integration-test` run is operator-side — the
sandbox here lacks docker-in-docker. The CI runner with Docker available
is where the matrix flips green. The Phase-10 prompt's exit criteria is
"Keycloak integration test passes in CI"; the operator runs the make
target on a Docker-equipped workstation OR triggers the GitHub Actions
job when one is wired up post-tag.

Not in this commit (deferred)
=============================

* GitHub Actions workflow that invokes `make keycloak-integration-test`
  on push. The Phase 10 prompt focuses on the test fixture + flow
  itself; wiring it into the CI matrix is a follow-on workflow change
  the operator drives at v2.1.0 tag time.
* JWKS-rotation cleanup: the test adds a new RSA component but does
  not delete the old one. Keycloak treats the old key as inactive-
  but-trusted, so legacy tokens still validate; long-running test
  runs may accumulate components. Acceptable for ephemeral test
  fixtures.
2026-05-10 07:54:36 +00:00

333 lines
14 KiB
Makefile

.PHONY: help build run test lint verify verify-docs verify-deploy loadtest acme-cert-manager-test acme-rfc-conformance-test keycloak-integration-test okta-smoke-test clean docker-up docker-down migrate-up migrate-down generate test-cover frontend-build qa-stats
# Default target - show help
help:
@echo "Certctl Development Commands"
@echo "============================="
@echo ""
@echo "Build & Run:"
@echo " make build Build server and agent binaries"
@echo " make run Run server locally (requires DB)"
@echo " make run-agent Run agent locally"
@echo ""
@echo "Testing & Quality:"
@echo " make test Run all tests"
@echo " make test-verbose Run tests with verbose output"
@echo " make lint Run linter (golangci-lint)"
@echo " make fmt Format code with gofmt"
@echo " make verify Pre-commit gate: fmt + vet + lint + test (CI-parity)"
@echo " make verify-docs Pre-tag gate: QA-doc drift checks (operator-facing docs)"
@echo " make verify-deploy Pre-push gate: digest validity + OpenAPI parity + docker build smoke"
@echo " make loadtest k6 throughput run against postgres + certctl (NOT in verify; manual + cron only)"
@echo ""
@echo "Database:"
@echo " make migrate-up Run migrations (requires DB_URL)"
@echo " make migrate-down Rollback last migration"
@echo " make db-seed Seed database with test data"
@echo ""
@echo "Docker:"
@echo " make docker-build Build Docker images"
@echo " make docker-up Start Docker Compose stack"
@echo " make docker-down Stop Docker Compose stack"
@echo " make docker-logs View Docker logs"
@echo " make docker-clean Remove Docker resources"
@echo ""
@echo "Code Generation:"
@echo " make generate Run go generate"
@echo " make clean Clean build artifacts"
@echo ""
# Build targets
build:
@echo "Building server and agent..."
mkdir -p bin
CGO_ENABLED=0 go build -o bin/server ./cmd/server
CGO_ENABLED=0 go build -o bin/agent ./cmd/agent
@echo "Build complete: bin/server, bin/agent"
build-server:
@echo "Building server..."
mkdir -p bin
CGO_ENABLED=0 go build -o bin/server ./cmd/server
@echo "Server build complete"
build-agent:
@echo "Building agent..."
mkdir -p bin
CGO_ENABLED=0 go build -o bin/agent ./cmd/agent
@echo "Agent build complete"
# Run targets
run: build-server
@echo "Starting server (requires DATABASE_URL or DB_* env vars)..."
./bin/server
run-agent: build-agent
@echo "Starting agent (requires SERVER_URL and API_KEY env vars)..."
./bin/agent
# Testing targets
test:
@echo "Running tests..."
go test ./...
test-verbose:
@echo "Running tests with verbose output..."
go test -v ./...
test-coverage:
@echo "Running tests with coverage..."
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
test-cover:
@echo "Running tests with coverage..."
go test ./internal/service/... ./internal/api/handler/... ./internal/integration/... -count=1 -cover -coverprofile=coverage.out
@echo "Coverage report: coverage.out"
# Linting targets
lint:
@echo "Running golangci-lint..."
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
golangci-lint run ./...
fmt:
@echo "Formatting code..."
go fmt ./...
@echo "Code formatted"
vet:
@echo "Running go vet..."
go vet ./...
# verify: aggregate pre-commit gate. Mirrors what CI enforces, so
# running `make verify` locally before committing prevents the
# class of breakages that ship green-locally / red-on-CI (e.g.
# Bundle-9's ST1018 invisible-Unicode-literal hits, which `go vet`
# alone cannot catch — staticcheck under golangci-lint does).
verify:
@echo "==> fmt"
@go fmt ./... | { ! grep -q '.'; } || (echo "gofmt produced changes — commit them" && exit 1)
@echo "==> go vet ./..."
@go vet ./...
@echo "==> golangci-lint run ./... (incl. staticcheck ST*)"
@which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest)
@golangci-lint run ./... --timeout 5m
@echo "==> go test -short ./..."
@go test -short -count=1 ./...
@echo ""
@echo "verify: PASS — safe to commit"
# verify-docs: pre-tag gate. Runs the QA-doc seed-count drift guard
# that ci-pipeline-cleanup Phase 11 / frozen decision 0.13 moved out
# of CI (was per-push blocking; now operator-runs pre-tag). Protects
# docs/contributor/qa-test-suite.md::Seed Data Reference from
# drifting vs migrations/seed_demo.sql. Operator-facing docs only —
# not product-affecting.
#
# The QA-doc Part-count drift guard retired in the 2026-05-04 docs
# overhaul Phase 5 when docs/testing-guide.md was pruned (its content
# dispersed across the audience-organized doc tree); the Part-count
# class no longer exists outside the qa_test.go file itself.
verify-docs:
@echo "==> QA-doc seed-count drift"
@bash scripts/qa-doc-seed-count.sh
@echo ""
@echo "verify-docs: PASS — safe to tag"
# verify-deploy: optional pre-push gate. Runs the digest-validity check,
# the OpenAPI ↔ handler parity check, and a Docker build smoke for the
# production images (server + agent only — fast subset for local; CI
# builds all 4 Dockerfiles per ci-pipeline-cleanup Phase 8 / frozen
# decision 0.10).
#
# Per ci-pipeline-cleanup bundle Phase 11 / frozen decision 0.13.
verify-deploy:
@echo "==> Digest validity"
@bash scripts/ci-guards/digest-validity.sh
@echo "==> OpenAPI ↔ handler parity"
@bash scripts/ci-guards/openapi-handler-parity.sh
@echo "==> Docker build smoke (server + agent — fast subset)"
@docker build -f Dockerfile -t certctl:verify .
@docker build -f Dockerfile.agent -t certctl-agent:verify .
@echo ""
@echo "verify-deploy: PASS — safe to push"
# Load-test harness — closes the #8 acquisition-readiness blocker from
# the 2026-05-01 issuer coverage audit. Boots a minimal certctl stack
# (postgres + tls-init + certctl-server) and runs k6 against the API
# tier for ~5 minutes. Exits non-zero on any threshold breach.
#
# NOT in `make verify` — load tests take minutes, not seconds, and
# don't gate per-PR signal. CI gates this behind workflow_dispatch +
# weekly cron in .github/workflows/loadtest.yml. See
# deploy/test/loadtest/README.md for thresholds, baseline, and how to
# interpret a regression.
loadtest:
@echo "==> spinning up postgres + certctl + k6 driver (this takes ~7m)"
@cd deploy/test/loadtest && docker compose up --build --abort-on-container-exit --exit-code-from k6
@echo ""
@echo "==> results landed in deploy/test/loadtest/results/"
@if [ -f deploy/test/loadtest/results/summary.txt ]; then cat deploy/test/loadtest/results/summary.txt; fi
# Auth Bundle 2 Phase 10 — Keycloak end-to-end OIDC integration test.
# Boots a Keycloak container via testcontainers-go (quay.io/keycloak:25.0),
# imports a canned realm with two groups + two users, and drives the
# full OIDC flow against the certctl service: discovery + JWKS,
# auth-code login, group-claim parsing, group-role mapping, session
# mint, and JWKS rotation.
#
# Build-tag-gated under `integration` so `make verify` (which runs
# go test -short) NEVER pulls in the 60-90s Keycloak boot. Requires a
# local Docker daemon. Skips cleanly with t.Skip() when -short is set.
keycloak-integration-test:
@echo "==> running Keycloak OIDC integration test (requires Docker)"
@go test -tags=integration -count=1 -timeout=10m \
./internal/auth/oidc/...
# Auth Bundle 2 Phase 10 — optional Okta smoke test. Gated behind TWO
# build tags (integration + okta_smoke) so it only runs when invoked
# manually against the operator's own Okta dev tenant. Requires the
# OKTA_ISSUER + OKTA_CLIENT_ID + OKTA_CLIENT_SECRET env vars; the test
# t.Skip's with a clear message when any are missing. Documented in
# internal/auth/oidc/integration_okta_smoke_test.go.
okta-smoke-test:
@echo "==> running Okta smoke test (requires OKTA_ISSUER / _CLIENT_ID / _CLIENT_SECRET env vars)"
@go test -tags='integration okta_smoke' -count=1 -timeout=2m \
./internal/auth/oidc/...
# Phase 5 — kind-driven cert-manager integration test. Requires
# `kind`, `kubectl`, `helm`, and a local Docker daemon. Sets
# KIND_AVAILABLE=1 so the test runs (it skips cleanly when unset, which
# is the CI default — kind is too heavy for per-PR CI). The test
# brings up a fresh cluster, installs cert-manager 1.15, helm-installs
# certctl-test, applies a ClusterIssuer + Certificate, and asserts the
# Secret lands.
acme-cert-manager-test:
@echo "==> running cert-manager integration test (requires kind/kubectl/helm)"
@KIND_AVAILABLE=1 go test -tags=integration -count=1 -timeout=15m \
./deploy/test/acme-integration/...
# Phase 5 — RFC 8555 conformance against `lego` driving the certctl
# server. Hermetic: brings up a single certctl-server via docker
# compose, points lego at it, runs the conformance scenarios. Skips
# when the operator hasn't built the test image (`make docker-build`
# first).
acme-rfc-conformance-test:
@echo "==> running RFC 8555 conformance via lego"
@if ! command -v lego >/dev/null 2>&1; then \
echo "lego not installed — go install github.com/go-acme/lego/v4/cmd/lego@latest"; \
exit 1; \
fi
@cd deploy/test/loadtest && docker compose up -d certctl postgres
@sleep 8
@CERTCTL_ACME_DIR=https://localhost:8443/acme/profile/prof-test/directory \
bash deploy/test/acme-integration/conformance-lego.sh
@cd deploy/test/loadtest && docker compose down
# Database targets (requires migrate tool)
migrate-up:
@echo "Running migrations..."
@which migrate > /dev/null || (echo "Installing migrate CLI..." && go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest)
migrate -path migrations -database "${DB_URL:-postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable}" up
migrate-down:
@echo "Rolling back last migration..."
@which migrate > /dev/null || (echo "Installing migrate CLI..." && go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest)
migrate -path migrations -database "${DB_URL:-postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable}" down 1
migrate-status:
@echo "Checking migration status..."
@which migrate > /dev/null || (echo "Installing migrate CLI..." && go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest)
migrate -path migrations -database "${DB_URL:-postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable}" version
db-seed:
@echo "Seeding database with test data..."
go run ./scripts/seed/main.go
# Docker targets
docker-build:
@echo "Building Docker images..."
docker-compose -f deploy/docker-compose.yml build
docker-up:
@echo "Starting Docker Compose stack..."
docker-compose -f deploy/docker-compose.yml up -d
@echo "Stack running. Access server at http://localhost:8443"
docker-up-dev:
@echo "Starting Docker Compose stack (dev mode)..."
docker-compose -f deploy/docker-compose.yml -f deploy/docker-compose.dev.yml up -d
@echo "Stack running. PgAdmin at http://localhost:5050"
docker-down:
@echo "Stopping Docker Compose stack..."
docker-compose -f deploy/docker-compose.yml down
docker-logs:
docker-compose -f deploy/docker-compose.yml logs -f
docker-logs-server:
docker-compose -f deploy/docker-compose.yml logs -f certctl-server
docker-logs-agent:
docker-compose -f deploy/docker-compose.yml logs -f certctl-agent
docker-clean:
@echo "Removing Docker resources..."
docker-compose -f deploy/docker-compose.yml down -v
@echo "Cleaned up"
# Code generation
generate:
@echo "Running go generate..."
go generate ./...
@echo "Code generation complete"
# Frontend build
frontend-build:
@echo "Building frontend..."
cd web && npm ci && npx vite build
@echo "Frontend build complete"
# QA Suite Stats — Bundle P / Strengthening #8.
# Single source-of-truth for every count claim in
# docs/contributor/qa-test-suite.md. The Strengthening #6 CI drift guards
# (now scoped to the seed-count class only — the Part-count class retired
# in the 2026-05-04 docs overhaul Phase 5 when testing-guide.md was
# pruned) consume the same numbers, eliminating the doc-drift class
# structurally.
qa-stats:
@echo "=== certctl QA Suite Stats ==="
@echo "Date: $$(date +%Y-%m-%d)"
@echo "HEAD: $$(git rev-parse HEAD 2>/dev/null || echo 'not-a-git-repo')"
@echo ""
@echo "Backend test files: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | wc -l | tr -d ' ')"
@echo "Backend Test functions: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c '^func Test' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
@echo "Backend t.Run subtests: $$(find . -name '*_test.go' -not -path './web/*' 2>/dev/null | xargs grep -c 't\.Run(' 2>/dev/null | awk -F: '{s+=$$2} END{print s+0}')"
@echo "Frontend test files: $$(find web/src -name '*.test.ts' -o -name '*.test.tsx' 2>/dev/null | wc -l | tr -d ' ')"
@echo "Fuzz targets: $$(grep -rE 'func Fuzz[A-Z]' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
@echo "t.Skip sites: $$(grep -rE 't\.Skip(Now|f)?\(' --include='*_test.go' . 2>/dev/null | wc -l | tr -d ' ')"
@echo "qa_test.go Part_ subtests: $$(grep -cE 't\.Run\(\"Part[0-9]+_' deploy/test/qa_test.go 2>/dev/null || echo 0)"
@echo "Seed unique mc-* IDs: $$(grep -oE "mc-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
@echo "Seed unique ag-* IDs: $$(grep -oE "ag-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (incl. agent_groups; agents-table count is 13 incl. agent-demo-1 + 3 cloud sentinels + server-scanner)"
@echo "Seed unique iss-* IDs: $$(grep -oE "iss-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ') (issuers table count is 13)"
@echo "Seed unique tgt-* IDs: $$(grep -oE "tgt-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
@echo "Seed unique nst-* IDs: $$(grep -oE "nst-[a-z0-9_-]+" migrations/seed_demo.sql 2>/dev/null | sort -u | wc -l | tr -d ' ')"
# Cleanup
clean:
@echo "Cleaning build artifacts..."
rm -rf bin/ dist/ coverage.out coverage.html
go clean -testcache
cd web && rm -rf node_modules dist
@echo "Cleanup complete"
install-tools:
@echo "Installing development tools..."
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
go install github.com/cosmtrek/air@latest
@echo "Tools installed"