mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:01:31 +00:00
52248be717
Breaking change release. Plaintext HTTP listener removed. The certctl control plane now terminates TLS 1.3 on :8443 via http.Server.ListenAndServeTLS. No CERTCTL_TLS_ENABLED=false escape hatch. No dual-listener mode. One-step cutover per docs/upgrade-to-tls.md. Server - cmd/server/tls.go: certHolder with SIGHUP hot-reload + atomic cert swap, buildServerTLSConfig (TLS 1.3 min, GetCertificate callback), preflightServerTLS validation - cmd/server/main.go: ListenAndServeTLS in place of ListenAndServe, watchSIGHUP wiring, cert/key path config threading - tls_test.go: 418-line regression coverage of reload, preflight, callback behavior, SAN validation Config - CERTCTL_TLS_CERT_PATH / CERTCTL_TLS_KEY_PATH (required) - Plaintext rejection: agents/CLI/MCP pre-flight-fail on http:// URLs with a pointer to docs/upgrade-to-tls.md Agents, CLI, MCP - All three pre-flight-reject http:// URLs with fail-loud diagnostic - CERTCTL_SERVER_CA_BUNDLE_PATH for private-CA trust - CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY for dev-only bypass (loud warning on startup) - install-agent.sh emits both vars as commented template lines docker-compose - certctl-tls-init sidecar generates SAN-valid self-signed cert into deploy/test/certs/ on first boot - All demo-stack curls pin against ca.crt with --cacert Helm chart - Three TLS provisioning modes, exactly one required: - server.tls.existingSecret (operator-supplied) - server.tls.certManager.enabled (cert-manager integration) - server.tls.selfSigned.enabled (eval only — not for production) - server-certificate.yaml template for cert-manager mode - helm install without a TLS source fails at template render with a pointer to docs/tls.md CI - .github/workflows/ci.yml Helm Chart Validation step renders the chart in both existingSecret and cert-manager modes, plus an inverse guard-regression test that asserts helm template MUST refuse to render when no TLS source is configured. Previously the single `helm template` invocation hit the certctl.tls.required fail-loud guard and exit-1'd CI. Four invocations now: lint (existingSecret), template (existingSecret), template (cert-manager), template (no args — must fail). Integration tests - deploy/test/integration_test.go stands up the Compose stack over HTTPS, extracts the CA bundle, and exercises every certctl API over https://localhost:8443 - All 34 integration subtests green (per Phase 8 local CI-parity) Documentation - New: docs/tls.md (provisioning patterns, rotation, SIGHUP reload) - New: docs/upgrade-to-tls.md (one-step cutover, no-downgrade warnings, fleet-roll sequencing) - CHANGELOG.md: v2.2.0 "HTTPS Everywhere — The Irony" entry (file heading unchanged; release tag is v2.0.47) - All curls in docs/, examples/, deploy/helm/ guides use https://localhost:8443 --cacert Verification - grep -rn "ListenAndServe[^T]" cmd/ internal/ → 0 hits - grep -rn "\"http://" cmd/ internal/ → 2 benign hits (Caddy admin API default, SSRF doc comment) — zero certctl endpoints - Tasks #197–#206 (Phases 0–8) all closed in the tracker Files: 65 changed, 3489 insertions, 372 deletions (pre-CI-fix).
1877 lines
60 KiB
Go
1877 lines
60 KiB
Go
//go:build qa
|
|
|
|
// Package integration_test provides the certctl V2.1 Release QA suite.
|
|
//
|
|
// This file automates every scriptable test from docs/testing-guide.md against
|
|
// a running Docker Compose demo stack. Tests that require a browser, external
|
|
// service (Vault, DigiCert, Sectigo, Google CAS), Windows, or Kubernetes are
|
|
// skipped with a reason.
|
|
//
|
|
// Run:
|
|
//
|
|
// cd deploy && docker compose -f docker-compose.yml -f docker-compose.demo.yml up --build -d
|
|
// # Wait for healthy state (~15s)
|
|
// cd deploy/test && go test -tags qa -v -timeout 10m ./...
|
|
//
|
|
// Run a single Part:
|
|
//
|
|
// go test -tags qa -v -run TestQA/Part14 ./...
|
|
//
|
|
// Environment overrides:
|
|
//
|
|
// CERTCTL_QA_SERVER_URL (default: https://localhost:8443)
|
|
// CERTCTL_QA_API_KEY (default: change-me-in-production)
|
|
// CERTCTL_QA_DB_URL (default: postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable)
|
|
// CERTCTL_QA_REPO_DIR (default: ../.. — the certctl repo root)
|
|
// CERTCTL_QA_CA_BUNDLE (default: ./certs/ca.crt — the demo stack's init container writes here)
|
|
// CERTCTL_QA_INSECURE (default: false — set to "true" to skip TLS verify, e.g. before the init container finishes)
|
|
//
|
|
// TLS note (HTTPS-Everywhere M-007, Phase 6): the demo compose stack now
|
|
// listens on https://localhost:8443 with a self-signed cert written by the
|
|
// tls-init container. This suite pins the issuing CA via
|
|
// CERTCTL_QA_CA_BUNDLE so cert rotation or a tampered proxy fails the
|
|
// handshake instead of being silently trusted. CERTCTL_QA_INSECURE="true"
|
|
// is an explicit opt-out for bootstrap scenarios — there is no silent
|
|
// plaintext downgrade, matching the server-side pre-flight guard added in
|
|
// Phase 5 (task #203).
|
|
package integration_test
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// QA Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func qaEnv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
var (
|
|
qaServerURL = qaEnv("CERTCTL_QA_SERVER_URL", "https://localhost:8443")
|
|
qaAPIKey = qaEnv("CERTCTL_QA_API_KEY", "change-me-in-production")
|
|
qaDBURL = qaEnv("CERTCTL_QA_DB_URL", "postgres://certctl:certctl@localhost:5432/certctl?sslmode=disable")
|
|
qaRepoDir = qaEnv("CERTCTL_QA_REPO_DIR", filepath.Join("..", ".."))
|
|
qaCABundlePath = qaEnv("CERTCTL_QA_CA_BUNDLE", "./certs/ca.crt")
|
|
qaInsecure = strings.EqualFold(os.Getenv("CERTCTL_QA_INSECURE"), "true")
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// QA HTTP client
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type qaClient struct {
|
|
http *http.Client
|
|
baseURL string
|
|
apiKey string
|
|
}
|
|
|
|
// buildQATLSConfig returns the *tls.Config used by every qaClient. TLS 1.3
|
|
// minimum matches the server-side config pinned in Phase 2 (cmd/server).
|
|
// When CERTCTL_QA_INSECURE=true we skip verification entirely — useful
|
|
// when running against a compose stack where the tls-init container hasn't
|
|
// written ca.crt yet, or when pointing at a dev server with a rotated cert.
|
|
// Otherwise we pin CERTCTL_QA_CA_BUNDLE and panic on read/parse failure
|
|
// rather than silently downgrading to the system trust store (which would
|
|
// mask a missing init container).
|
|
func buildQATLSConfig() *tls.Config {
|
|
cfg := &tls.Config{MinVersion: tls.VersionTLS13}
|
|
if qaInsecure {
|
|
cfg.InsecureSkipVerify = true
|
|
return cfg
|
|
}
|
|
pem, err := os.ReadFile(qaCABundlePath)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("qa test: read CA bundle %q: %v — set CERTCTL_QA_CA_BUNDLE or CERTCTL_QA_INSECURE=true", qaCABundlePath, err))
|
|
}
|
|
pool := x509.NewCertPool()
|
|
if !pool.AppendCertsFromPEM(pem) {
|
|
panic(fmt.Sprintf("qa test: no PEM certificates parsed from %q", qaCABundlePath))
|
|
}
|
|
cfg.RootCAs = pool
|
|
return cfg
|
|
}
|
|
|
|
func newQAClient() *qaClient {
|
|
return &qaClient{
|
|
http: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
Transport: &http.Transport{TLSClientConfig: buildQATLSConfig()},
|
|
},
|
|
baseURL: qaServerURL,
|
|
apiKey: qaAPIKey,
|
|
}
|
|
}
|
|
|
|
func (c *qaClient) do(method, path string, body string) (*http.Response, error) {
|
|
var r io.Reader
|
|
if body != "" {
|
|
r = strings.NewReader(body)
|
|
}
|
|
req, err := http.NewRequest(method, c.baseURL+path, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.apiKey)
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return c.http.Do(req)
|
|
}
|
|
|
|
func (c *qaClient) get(path string) (*http.Response, error) { return c.do("GET", path, "") }
|
|
func (c *qaClient) post(path, body string) (*http.Response, error) { return c.do("POST", path, body) }
|
|
func (c *qaClient) put(path, body string) (*http.Response, error) { return c.do("PUT", path, body) }
|
|
func (c *qaClient) delete(path string) (*http.Response, error) { return c.do("DELETE", path, "") }
|
|
|
|
// statusCode makes a request and returns the HTTP status code.
|
|
func (c *qaClient) statusCode(method, path, body string) (int, error) {
|
|
resp, err := c.do(method, path, body)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode, nil
|
|
}
|
|
|
|
// getJSON makes a GET request and decodes the JSON response.
|
|
func (c *qaClient) getJSON(t *testing.T, path string, v interface{}) {
|
|
t.Helper()
|
|
resp, err := c.get(path)
|
|
if err != nil {
|
|
t.Fatalf("GET %s: %v", path, err)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
resp.Body.Close()
|
|
t.Fatalf("GET %s: status %d, body: %s", path, resp.StatusCode, string(body))
|
|
}
|
|
defer resp.Body.Close()
|
|
data, _ := io.ReadAll(resp.Body)
|
|
if err := json.Unmarshal(data, v); err != nil {
|
|
t.Fatalf("GET %s: decode JSON: %v (body: %s)", path, err, string(data))
|
|
}
|
|
}
|
|
|
|
// bodyStr makes a request and returns the body as a string.
|
|
func (c *qaClient) bodyStr(t *testing.T, method, path, body string) (int, string) {
|
|
t.Helper()
|
|
resp, err := c.do(method, path, body)
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, string(b)
|
|
}
|
|
|
|
// timedGet makes a GET request and returns the duration.
|
|
func (c *qaClient) timedGet(path string) (time.Duration, int, error) {
|
|
start := time.Now()
|
|
resp, err := c.do("GET", path, "")
|
|
elapsed := time.Since(start)
|
|
if err != nil {
|
|
return elapsed, 0, err
|
|
}
|
|
resp.Body.Close()
|
|
return elapsed, resp.StatusCode, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// JSON response helpers (lightweight, no internal imports)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type qaPagedResponse struct {
|
|
Data json.RawMessage `json:"data"`
|
|
Total int `json:"total"`
|
|
Page int `json:"page"`
|
|
PerPage int `json:"per_page"`
|
|
}
|
|
|
|
type qaCert struct {
|
|
ID string `json:"id"`
|
|
CommonName string `json:"common_name"`
|
|
Status string `json:"status"`
|
|
IssuerID string `json:"issuer_id"`
|
|
OwnerID *string `json:"owner_id"`
|
|
ProfileID *string `json:"certificate_profile_id"`
|
|
}
|
|
|
|
type qaJob struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
CertificateID string `json:"certificate_id"`
|
|
AgentID *string `json:"agent_id"`
|
|
}
|
|
|
|
type qaIssuer struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Source string `json:"source"`
|
|
Enabled bool `json:"enabled"`
|
|
Config json.RawMessage `json:"config"`
|
|
}
|
|
|
|
type qaTarget struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Source string `json:"source"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
type qaAgent struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
OS string `json:"os"`
|
|
Arch string `json:"architecture"`
|
|
}
|
|
|
|
type qaNotification struct {
|
|
ID string `json:"id"`
|
|
Read bool `json:"read"`
|
|
}
|
|
|
|
type qaStats struct {
|
|
TotalCertificates int `json:"total_certificates"`
|
|
ActiveCertificates int `json:"active_certificates"`
|
|
ExpiringCertificates int `json:"expiring_certificates"`
|
|
TotalAgents int `json:"total_agents"`
|
|
}
|
|
|
|
type qaMetrics struct {
|
|
Gauge map[string]interface{} `json:"gauge"`
|
|
Counter map[string]interface{} `json:"counter"`
|
|
Uptime float64 `json:"uptime_seconds"`
|
|
}
|
|
|
|
type qaDiscoveredCert struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
CommonName string `json:"common_name"`
|
|
Fingerprint string `json:"fingerprint_sha256"`
|
|
}
|
|
|
|
type qaDiscoverySummary struct {
|
|
Unmanaged int `json:"unmanaged"`
|
|
Managed int `json:"managed"`
|
|
Dismissed int `json:"dismissed"`
|
|
}
|
|
|
|
type qaNetworkScanTarget struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CIDRs []string `json:"cidrs"`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Source file helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func repoFile(relPath string) string {
|
|
return filepath.Join(qaRepoDir, relPath)
|
|
}
|
|
|
|
func fileContains(t *testing.T, relPath, substr string) {
|
|
t.Helper()
|
|
data, err := os.ReadFile(repoFile(relPath))
|
|
if err != nil {
|
|
t.Fatalf("read %s: %v", relPath, err)
|
|
}
|
|
if !strings.Contains(string(data), substr) {
|
|
t.Fatalf("%s does not contain %q", relPath, substr)
|
|
}
|
|
}
|
|
|
|
func fileExists(t *testing.T, relPath string) {
|
|
t.Helper()
|
|
if _, err := os.Stat(repoFile(relPath)); os.IsNotExist(err) {
|
|
t.Fatalf("file does not exist: %s", relPath)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Database helper
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type qaDB struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func openQADB(t *testing.T) *qaDB {
|
|
t.Helper()
|
|
db, err := sql.Open("postgres", qaDBURL)
|
|
if err != nil {
|
|
t.Fatalf("connect to QA DB: %v", err)
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
t.Fatalf("ping QA DB: %v", err)
|
|
}
|
|
return &qaDB{db: db}
|
|
}
|
|
|
|
func (d *qaDB) queryInt(t *testing.T, query string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := d.db.QueryRow(query).Scan(&n); err != nil {
|
|
t.Fatalf("queryInt: %v\nquery: %s", err, query)
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (d *qaDB) close() { d.db.Close() }
|
|
|
|
// ===========================================================================
|
|
// QA Test Suite
|
|
// ===========================================================================
|
|
|
|
func TestQA(t *testing.T) {
|
|
c := newQAClient()
|
|
|
|
// Verify server is reachable before running anything.
|
|
resp, err := c.get("/health")
|
|
if err != nil {
|
|
t.Fatalf("Server unreachable at %s: %v\nIs the Docker Compose stack running?", qaServerURL, err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("Server unhealthy: GET /health returned %d", resp.StatusCode)
|
|
}
|
|
|
|
// ===================================================================
|
|
// Part 1: Infrastructure & Deployment
|
|
// ===================================================================
|
|
t.Run("Part01_Infrastructure", func(t *testing.T) {
|
|
db := openQADB(t)
|
|
defer db.close()
|
|
|
|
t.Run("PostgreSQL_TableCount", func(t *testing.T) {
|
|
n := db.queryInt(t, `SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_type='BASE TABLE'`)
|
|
if n < 19 {
|
|
t.Fatalf("table count = %d, want >= 19", n)
|
|
}
|
|
})
|
|
|
|
t.Run("HealthEndpoint", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/health", "")
|
|
if code != 200 {
|
|
t.Fatalf("GET /health = %d, want 200", code)
|
|
}
|
|
})
|
|
|
|
t.Run("ReadyEndpoint", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/ready", "")
|
|
if code != 200 {
|
|
t.Fatalf("GET /ready = %d, want 200", code)
|
|
}
|
|
})
|
|
|
|
t.Run("SeedData_Certs", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/certificates", &pr)
|
|
if pr.Total < 10 {
|
|
t.Fatalf("seed certs = %d, want >= 10", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("SeedData_Agents", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/agents", &pr)
|
|
if pr.Total < 3 {
|
|
t.Fatalf("seed agents = %d, want >= 3", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("SeedData_Issuers", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/issuers", &pr)
|
|
if pr.Total < 3 {
|
|
t.Fatalf("seed issuers = %d, want >= 3", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("SeedData_Targets", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/targets", &pr)
|
|
if pr.Total < 3 {
|
|
t.Fatalf("seed targets = %d, want >= 3", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("SeedData_Policies", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/policies", &pr)
|
|
if pr.Total < 1 {
|
|
t.Fatalf("seed policies = %d, want >= 1", pr.Total)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 2: Authentication & Security
|
|
// ===================================================================
|
|
t.Run("Part02_Auth", func(t *testing.T) {
|
|
t.Run("NoAuth_Returns401", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", qaServerURL+"/api/v1/certificates", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Fatalf("no-auth status = %d, want 401", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("BadKey_Returns401", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", qaServerURL+"/api/v1/certificates", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong-key")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 401 {
|
|
t.Fatalf("bad-key status = %d, want 401", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("HealthEndpoint_NoAuth", func(t *testing.T) {
|
|
req, _ := http.NewRequest("GET", qaServerURL+"/health", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("/health without auth = %d, want 200", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("PrivateKey_NotInCertDetail", func(t *testing.T) {
|
|
_, body := c.bodyStr(t, "GET", "/api/v1/certificates?per_page=1", "")
|
|
if strings.Contains(body, "PRIVATE KEY") {
|
|
t.Fatal("API response contains private key material")
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 3: Certificate Lifecycle (CRUD)
|
|
// ===================================================================
|
|
t.Run("Part03_CertCRUD", func(t *testing.T) {
|
|
t.Run("Create_Minimal", func(t *testing.T) {
|
|
// C-001 scope-expansion: the handler's ValidateRequired
|
|
// contract now gates common_name, owner_id, team_id,
|
|
// issuer_id, name, and renewal_policy_id. A 3-field
|
|
// payload would 400 regardless of the id hint, so the
|
|
// "minimal" variant carries every required field.
|
|
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
|
|
"id": "mc-qa-minimal",
|
|
"name": "qa-minimal",
|
|
"common_name": "qa-minimal.example.com",
|
|
"issuer_id": "iss-local",
|
|
"owner_id": "o-alice",
|
|
"team_id": "t-platform",
|
|
"renewal_policy_id": "rp-standard"
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create cert: status %d, body: %s", code, body)
|
|
}
|
|
})
|
|
|
|
t.Run("Create_Full", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "POST", "/api/v1/certificates", `{
|
|
"id": "mc-qa-full",
|
|
"name": "qa-full",
|
|
"common_name": "qa-full.example.com",
|
|
"sans": ["qa-full-alt.example.com"],
|
|
"issuer_id": "iss-local",
|
|
"environment": "staging",
|
|
"owner_id": "o-alice",
|
|
"team_id": "t-platform",
|
|
"renewal_policy_id": "rp-standard"
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create cert: status %d, body: %s", code, body)
|
|
}
|
|
})
|
|
|
|
t.Run("Get_ByID", func(t *testing.T) {
|
|
var cert qaCert
|
|
c.getJSON(t, "/api/v1/certificates/mc-qa-minimal", &cert)
|
|
if cert.CommonName != "qa-minimal.example.com" {
|
|
t.Fatalf("CN = %q, want qa-minimal.example.com", cert.CommonName)
|
|
}
|
|
})
|
|
|
|
t.Run("Get_NotFound", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/api/v1/certificates/nonexistent-cert-id", "")
|
|
if code != 404 {
|
|
t.Fatalf("nonexistent cert = %d, want 404", code)
|
|
}
|
|
})
|
|
|
|
t.Run("List_Pagination", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/certificates?per_page=5", &pr)
|
|
if pr.PerPage != 5 {
|
|
t.Fatalf("per_page = %d, want 5", pr.PerPage)
|
|
}
|
|
if pr.Total < 10 {
|
|
t.Fatalf("total = %d, want >= 10", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("Filter_ByStatus", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/certificates?status=Active", &pr)
|
|
var certs []qaCert
|
|
json.Unmarshal(pr.Data, &certs)
|
|
for _, cert := range certs {
|
|
if cert.Status != "Active" {
|
|
t.Fatalf("filter returned non-Active cert: %s status=%s", cert.ID, cert.Status)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("Filter_ByIssuer", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/certificates?issuer_id=iss-local", &pr)
|
|
var certs []qaCert
|
|
json.Unmarshal(pr.Data, &certs)
|
|
for _, cert := range certs {
|
|
if cert.IssuerID != "iss-local" {
|
|
t.Fatalf("filter returned wrong issuer: %s issuer=%s", cert.ID, cert.IssuerID)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("SparseFields", func(t *testing.T) {
|
|
_, body := c.bodyStr(t, "GET", "/api/v1/certificates?fields=id,common_name&per_page=1", "")
|
|
if !strings.Contains(body, "id") || !strings.Contains(body, "common_name") {
|
|
t.Fatalf("sparse fields missing expected fields: %s", body)
|
|
}
|
|
})
|
|
|
|
t.Run("Update", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "PUT", "/api/v1/certificates/mc-qa-minimal", `{"environment":"production"}`)
|
|
if code != 200 {
|
|
t.Fatalf("update cert = %d, want 200", code)
|
|
}
|
|
})
|
|
|
|
t.Run("Archive", func(t *testing.T) {
|
|
code, _ := c.statusCode("DELETE", "/api/v1/certificates/mc-qa-full", "")
|
|
if code != 204 && code != 200 {
|
|
t.Fatalf("archive cert = %d, want 204 or 200", code)
|
|
}
|
|
})
|
|
|
|
// Cleanup
|
|
t.Cleanup(func() {
|
|
c.delete("/api/v1/certificates/mc-qa-minimal")
|
|
c.delete("/api/v1/certificates/mc-qa-full")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 4: Renewal Workflow
|
|
// ===================================================================
|
|
t.Run("Part04_Renewal", func(t *testing.T) {
|
|
t.Run("TriggerRenewal_CreatesJob", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "POST", "/api/v1/certificates/mc-web-prod/renew", "")
|
|
if code != 200 && code != 201 && code != 202 {
|
|
t.Fatalf("trigger renewal = %d, body: %s", code, body)
|
|
}
|
|
})
|
|
|
|
t.Run("Renewal_NonexistentCert_404", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates/nonexistent/renew", "")
|
|
if code != 404 {
|
|
t.Fatalf("renew nonexistent = %d, want 404", code)
|
|
}
|
|
})
|
|
|
|
t.Run("AgentWork_ReturnsPendingJobs", func(t *testing.T) {
|
|
// Use a known agent from seed data (ag-web-prod in seed_demo.sql)
|
|
_, body := c.bodyStr(t, "GET", "/api/v1/agents/ag-web-prod/work", "")
|
|
// Should return JSON array (even if empty)
|
|
if !strings.HasPrefix(strings.TrimSpace(body), "[") && !strings.HasPrefix(strings.TrimSpace(body), "null") {
|
|
t.Fatalf("agent work not a JSON array: %s", body[:min(len(body), 100)])
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 5: Revocation
|
|
// ===================================================================
|
|
t.Run("Part05_Revocation", func(t *testing.T) {
|
|
t.Run("Revoke_DefaultReason", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/certificates/mc-blog-prod/revoke", `{}`)
|
|
if code != 200 {
|
|
t.Fatalf("revoke = %d, want 200", code)
|
|
}
|
|
})
|
|
|
|
t.Run("Revoke_AlreadyRevoked", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates/mc-blog-prod/revoke", `{"reason":"keyCompromise"}`)
|
|
if code != 200 && code != 409 {
|
|
t.Fatalf("re-revoke = %d, want 200 or 409", code)
|
|
}
|
|
})
|
|
|
|
t.Run("Revoke_Nonexistent", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates/nonexistent/revoke", `{}`)
|
|
if code != 404 {
|
|
t.Fatalf("revoke nonexistent = %d, want 404", code)
|
|
}
|
|
})
|
|
|
|
t.Run("Revoke_InvalidReason", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates/mc-grpc-prod/revoke", `{"reason":"madeUpReason"}`)
|
|
if code != 400 {
|
|
t.Fatalf("invalid reason = %d, want 400", code)
|
|
}
|
|
})
|
|
|
|
// M-006: The non-standard JSON CRL endpoint was removed. RFC 5280 §5
|
|
// defines only the DER wire format, now served unauthenticated at
|
|
// `/.well-known/pki/crl/{issuer_id}` per RFC 8615. Use a plain
|
|
// http.Get — no Bearer — to prove the endpoint is reachable by
|
|
// relying parties with no API credentials.
|
|
t.Run("CRL_DER_Unauthenticated", func(t *testing.T) {
|
|
resp, err := http.Get(qaServerURL + "/.well-known/pki/crl/iss-local")
|
|
if err != nil {
|
|
t.Fatalf("GET DER CRL: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
b, _ := io.ReadAll(resp.Body)
|
|
t.Fatalf("CRL = %d (body=%s)", resp.StatusCode, string(b))
|
|
}
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/pkix-crl" {
|
|
t.Errorf("Content-Type: got %q, want %q", ct, "application/pkix-crl")
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read CRL body: %v", err)
|
|
}
|
|
if len(body) == 0 {
|
|
t.Fatal("CRL body empty")
|
|
}
|
|
crl, err := x509.ParseRevocationList(body)
|
|
if err != nil {
|
|
t.Fatalf("parse DER CRL: %v", err)
|
|
}
|
|
if len(crl.RevokedCertificateEntries) < 1 {
|
|
t.Fatalf("CRL entries: got %d, want >= 1", len(crl.RevokedCertificateEntries))
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 6: Policies & Profiles
|
|
// ===================================================================
|
|
t.Run("Part06_Policies", func(t *testing.T) {
|
|
t.Run("ListPolicies", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/policies", &pr)
|
|
if pr.Total < 1 {
|
|
t.Fatalf("policies = %d, want >= 1", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("CreatePolicy", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/policies", `{
|
|
"id": "rp-qa", "name": "QA Policy", "type": "AllowedDomains",
|
|
"config": {"domains": ["*.example.com"]}
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create policy = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("InvalidPolicyType", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/policies", `{
|
|
"id": "rp-bad", "name": "Bad", "type": "invalid_type",
|
|
"config": {}
|
|
}`)
|
|
if code != 400 {
|
|
t.Fatalf("invalid type = %d, want 400", code)
|
|
}
|
|
})
|
|
|
|
t.Run("DeletePolicy", func(t *testing.T) {
|
|
code, _ := c.statusCode("DELETE", "/api/v1/policies/rp-qa", "")
|
|
if code != 204 && code != 200 {
|
|
t.Fatalf("delete policy = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("ListProfiles", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/profiles", &pr)
|
|
if pr.Total < 1 {
|
|
t.Fatalf("profiles = %d, want >= 1", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateProfile", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/profiles", `{
|
|
"id": "prof-qa", "name": "QA Profile",
|
|
"allowed_key_algorithms": [{"algorithm":"RSA","min_size":2048},{"algorithm":"ECDSA","min_size":256}],
|
|
"max_ttl_seconds": 7776000
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create profile = %d", code)
|
|
}
|
|
t.Cleanup(func() { c.delete("/api/v1/profiles/prof-qa") })
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 7: Ownership, Teams & Agent Groups
|
|
// ===================================================================
|
|
t.Run("Part07_Ownership", func(t *testing.T) {
|
|
t.Run("ListTeams", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/teams", &pr)
|
|
if pr.Total < 1 {
|
|
t.Fatalf("teams = %d, want >= 1", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("TeamCRUD", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/teams", `{"id":"t-qa","name":"QA Team"}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create team = %d", code)
|
|
}
|
|
code, _ = c.statusCode("DELETE", "/api/v1/teams/t-qa", "")
|
|
if code != 204 && code != 200 {
|
|
t.Fatalf("delete team = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("OwnerCRUD", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/owners", `{
|
|
"id":"o-qa","name":"QA Owner","email":"qa@example.com","team_id":"t-platform"
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create owner = %d", code)
|
|
}
|
|
t.Cleanup(func() { c.delete("/api/v1/owners/o-qa") })
|
|
})
|
|
|
|
t.Run("ListAgentGroups", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/agent-groups", &pr)
|
|
if pr.Total < 1 {
|
|
t.Fatalf("agent groups = %d, want >= 1", pr.Total)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 8: Job System
|
|
// ===================================================================
|
|
t.Run("Part08_Jobs", func(t *testing.T) {
|
|
t.Run("ListJobs", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/jobs", &pr)
|
|
if pr.Total < 1 {
|
|
t.Fatalf("jobs = %d, want >= 1", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("GetNonexistentJob", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/api/v1/jobs/nonexistent", "")
|
|
if code != 404 {
|
|
t.Fatalf("nonexistent job = %d, want 404", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 9: Issuer Connectors
|
|
// ===================================================================
|
|
t.Run("Part09_Issuers", func(t *testing.T) {
|
|
t.Run("ListIssuers", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/issuers", &pr)
|
|
if pr.Total < 3 {
|
|
t.Fatalf("issuers = %d, want >= 3", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("GetIssuerDetail", func(t *testing.T) {
|
|
var iss qaIssuer
|
|
c.getJSON(t, "/api/v1/issuers/iss-local", &iss)
|
|
if iss.ID != "iss-local" {
|
|
t.Fatalf("issuer ID = %q, want iss-local", iss.ID)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateIssuer", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/issuers", `{
|
|
"id":"iss-qa","name":"QA Issuer","type":"GenericCA","config":{}
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create issuer = %d", code)
|
|
}
|
|
t.Cleanup(func() { c.delete("/api/v1/issuers/iss-qa") })
|
|
})
|
|
|
|
t.Run("CreateIssuer_MissingName", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/issuers", `{"type":"GenericCA","config":{}}`)
|
|
if code != 400 {
|
|
t.Fatalf("missing name = %d, want 400", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 10-11: Sub-CA Mode, ARI — mostly manual (need real CA setup)
|
|
// ===================================================================
|
|
t.Run("Part10_SubCA", func(t *testing.T) {
|
|
t.Skip("Requires CA cert+key setup — manual test")
|
|
})
|
|
|
|
t.Run("Part11_ARI", func(t *testing.T) {
|
|
t.Skip("Requires ACME CA with ARI support — manual test")
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 12-13: Vault PKI, DigiCert — require external services
|
|
// ===================================================================
|
|
t.Run("Part12_VaultPKI", func(t *testing.T) {
|
|
t.Skip("Requires live Vault server — manual test")
|
|
})
|
|
|
|
t.Run("Part13_DigiCert", func(t *testing.T) {
|
|
t.Skip("Requires DigiCert sandbox — manual test")
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 14: Target Connectors & Deployment
|
|
// ===================================================================
|
|
t.Run("Part14_Targets", func(t *testing.T) {
|
|
t.Run("ListTargets", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/targets", &pr)
|
|
if pr.Total < 3 {
|
|
t.Fatalf("targets = %d, want >= 3", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateNGINXTarget", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/targets", `{
|
|
"id":"tgt-qa-nginx","name":"QA NGINX","type":"NGINX",
|
|
"config":{"cert_path":"/etc/nginx/ssl/cert.pem","key_path":"/etc/nginx/ssl/key.pem","reload_command":"nginx -s reload"}
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create target = %d", code)
|
|
}
|
|
t.Cleanup(func() { c.delete("/api/v1/targets/tgt-qa-nginx") })
|
|
})
|
|
|
|
t.Run("DeleteTarget_204", func(t *testing.T) {
|
|
c.post("/api/v1/targets", `{"id":"tgt-qa-del","name":"Delete Me","type":"NGINX","config":{}}`)
|
|
code, _ := c.statusCode("DELETE", "/api/v1/targets/tgt-qa-del", "")
|
|
if code != 204 {
|
|
t.Fatalf("delete target = %d, want 204", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 15-17: Apache/HAProxy, Traefik/Caddy, IIS — need real services or Windows
|
|
// ===================================================================
|
|
|
|
// ===================================================================
|
|
// Part 18: Agent Operations
|
|
// ===================================================================
|
|
t.Run("Part18_Agents", func(t *testing.T) {
|
|
t.Run("RegisterAgent", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/agents/ag-qa-new/heartbeat", `{
|
|
"os":"linux","architecture":"amd64","version":"1.0.0"
|
|
}`)
|
|
if code != 200 {
|
|
t.Fatalf("heartbeat = %d, want 200", code)
|
|
}
|
|
})
|
|
|
|
t.Run("AgentMetadata", func(t *testing.T) {
|
|
var agent qaAgent
|
|
c.getJSON(t, "/api/v1/agents/ag-qa-new", &agent)
|
|
if agent.OS != "linux" {
|
|
t.Fatalf("agent OS = %q, want linux", agent.OS)
|
|
}
|
|
if agent.Arch != "amd64" {
|
|
t.Fatalf("agent arch = %q, want amd64", agent.Arch)
|
|
}
|
|
})
|
|
|
|
t.Run("HeartbeatNonexistent", func(t *testing.T) {
|
|
// Heartbeat auto-creates agents, so this should succeed
|
|
code, _ := c.statusCode("POST", "/api/v1/agents/ag-qa-ghost/heartbeat", `{}`)
|
|
if code != 200 {
|
|
t.Fatalf("ghost heartbeat = %d, want 200", code)
|
|
}
|
|
t.Cleanup(func() {
|
|
c.delete("/api/v1/agents/ag-qa-new")
|
|
c.delete("/api/v1/agents/ag-qa-ghost")
|
|
})
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 19: Agent Work Routing
|
|
// ===================================================================
|
|
t.Run("Part19_WorkRouting", func(t *testing.T) {
|
|
t.Run("EmptyWork_NoTargets", func(t *testing.T) {
|
|
// Register agent with no targets
|
|
c.post("/api/v1/agents/ag-qa-notargets/heartbeat", `{}`)
|
|
_, body := c.bodyStr(t, "GET", "/api/v1/agents/ag-qa-notargets/work", "")
|
|
body = strings.TrimSpace(body)
|
|
if body != "[]" && body != "null" {
|
|
t.Fatalf("expected empty work, got: %s", body[:min(len(body), 200)])
|
|
}
|
|
t.Cleanup(func() { c.delete("/api/v1/agents/ag-qa-notargets") })
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 20: Post-Deployment TLS Verification
|
|
// ===================================================================
|
|
t.Run("Part20_Verification", func(t *testing.T) {
|
|
t.Run("GetVerification_NoJob", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/api/v1/jobs/nonexistent/verification", "")
|
|
if code != 404 {
|
|
t.Fatalf("verification for nonexistent job = %d, want 404", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 21: EST Server (RFC 7030)
|
|
// ===================================================================
|
|
t.Run("Part21_EST", func(t *testing.T) {
|
|
t.Run("CACerts", func(t *testing.T) {
|
|
// EST routes use r.Register() which applies full middleware (incl. auth)
|
|
resp, err := c.get("/.well-known/est/cacerts")
|
|
if err != nil {
|
|
t.Fatalf("GET cacerts: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("cacerts = %d, want 200", resp.StatusCode)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
if !strings.Contains(ct, "pkcs7") && !strings.Contains(ct, "application") {
|
|
t.Logf("cacerts content-type: %s (expected pkcs7-mime)", ct)
|
|
}
|
|
})
|
|
|
|
t.Run("CSRAttrs", func(t *testing.T) {
|
|
resp, err := c.get("/.well-known/est/csrattrs")
|
|
if err != nil {
|
|
t.Fatalf("GET csrattrs: %v", err)
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode != 200 && resp.StatusCode != 204 {
|
|
t.Fatalf("csrattrs = %d, want 200 or 204", resp.StatusCode)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 22: Certificate Export
|
|
// ===================================================================
|
|
t.Run("Part22_Export", func(t *testing.T) {
|
|
t.Run("ExportPEM", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "GET", "/api/v1/certificates/mc-web-prod/export/pem", "")
|
|
if code != 200 {
|
|
t.Fatalf("export PEM = %d", code)
|
|
}
|
|
if !strings.Contains(body, "certificate") && !strings.Contains(body, "pem") {
|
|
t.Logf("PEM export body (first 200 chars): %s", body[:min(len(body), 200)])
|
|
}
|
|
})
|
|
|
|
t.Run("ExportPKCS12", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/certificates/mc-web-prod/export/pkcs12", `{"password":"test123"}`)
|
|
// PKCS12 may fail if no cert version exists
|
|
if code != 200 && code != 404 {
|
|
t.Fatalf("export PKCS12 = %d, want 200 or 404", code)
|
|
}
|
|
})
|
|
|
|
t.Run("Export_Nonexistent", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/api/v1/certificates/nonexistent/export/pem", "")
|
|
if code != 404 {
|
|
t.Fatalf("export nonexistent = %d, want 404", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 25: Certificate Discovery
|
|
// ===================================================================
|
|
t.Run("Part25_Discovery", func(t *testing.T) {
|
|
t.Run("ListDiscovered", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/discovered-certificates", "")
|
|
if code != 200 {
|
|
t.Fatalf("list discovered = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("DiscoverySummary", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "GET", "/api/v1/discovery-summary", "")
|
|
if code != 200 {
|
|
t.Fatalf("discovery summary = %d", code)
|
|
}
|
|
if !strings.Contains(body, "unmanaged") {
|
|
t.Fatalf("summary missing unmanaged field")
|
|
}
|
|
})
|
|
|
|
t.Run("ListNetworkScanTargets", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/network-scan-targets", &pr)
|
|
if pr.Total < 3 {
|
|
t.Fatalf("scan targets = %d, want >= 3", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateNetworkScanTarget", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/network-scan-targets", `{
|
|
"id":"nst-qa","name":"QA Scan","cidrs":["10.0.0.0/24"],"ports":[443]
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create scan target = %d", code)
|
|
}
|
|
t.Cleanup(func() { c.delete("/api/v1/network-scan-targets/nst-qa") })
|
|
})
|
|
|
|
t.Run("InvalidCIDR", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/network-scan-targets", `{
|
|
"name":"Bad","cidrs":["not-a-cidr"],"ports":[443]
|
|
}`)
|
|
if code != 400 {
|
|
t.Fatalf("invalid CIDR = %d, want 400", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 26: Enhanced Query API
|
|
// ===================================================================
|
|
t.Run("Part26_QueryAPI", func(t *testing.T) {
|
|
t.Run("SortDescending", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/certificates?sort=-createdAt&per_page=5", "")
|
|
if code != 200 {
|
|
t.Fatalf("sort desc = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("CursorPagination", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "GET", "/api/v1/certificates?page_size=3", "")
|
|
if code != 200 {
|
|
t.Fatalf("cursor page = %d", code)
|
|
}
|
|
// Should have next_cursor or data
|
|
if !strings.Contains(body, "data") {
|
|
t.Fatalf("cursor response missing data")
|
|
}
|
|
})
|
|
|
|
t.Run("TimeRangeFilter", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/certificates?expires_before=2030-01-01T00:00:00Z", "")
|
|
if code != 200 {
|
|
t.Fatalf("time range = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("InvalidSortField", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/api/v1/certificates?sort=notAField", "")
|
|
if code != 400 {
|
|
t.Logf("invalid sort field = %d (may return 200 ignoring bad sort)", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 27: Request Body Size Limits
|
|
// ===================================================================
|
|
t.Run("Part27_BodyLimits", func(t *testing.T) {
|
|
t.Run("OversizedBody_Rejected", func(t *testing.T) {
|
|
// Send a 2MB body (default limit is 1MB)
|
|
bigBody := `{"name":"` + strings.Repeat("x", 2*1024*1024) + `"}`
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates", bigBody)
|
|
if code != 413 && code != 400 {
|
|
t.Fatalf("oversize body = %d, want 413 or 400", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 28-29: CLI, MCP — require compiled binaries
|
|
// ===================================================================
|
|
t.Run("Part28_CLI", func(t *testing.T) {
|
|
t.Skip("Requires compiled certctl-cli binary — manual test")
|
|
})
|
|
|
|
t.Run("Part29_MCP", func(t *testing.T) {
|
|
t.Skip("Requires compiled mcp-server binary + stdio — manual test")
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 30: Observability
|
|
// ===================================================================
|
|
t.Run("Part30_Observability", func(t *testing.T) {
|
|
t.Run("DashboardSummary", func(t *testing.T) {
|
|
var stats qaStats
|
|
c.getJSON(t, "/api/v1/stats/summary", &stats)
|
|
if stats.TotalCertificates < 10 {
|
|
t.Fatalf("total certs = %d, want >= 10", stats.TotalCertificates)
|
|
}
|
|
})
|
|
|
|
t.Run("CertsByStatus", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "GET", "/api/v1/stats/certificates-by-status", "")
|
|
if code != 200 {
|
|
t.Fatalf("certs by status = %d", code)
|
|
}
|
|
if !strings.Contains(body, "Active") {
|
|
t.Fatalf("missing Active status in response")
|
|
}
|
|
})
|
|
|
|
t.Run("ExpirationTimeline", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/stats/expiration-timeline?days=90", "")
|
|
if code != 200 {
|
|
t.Fatalf("expiration timeline = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("JobTrends", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/stats/job-trends?days=30", "")
|
|
if code != 200 {
|
|
t.Fatalf("job trends = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("IssuanceRate", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/stats/issuance-rate?days=30", "")
|
|
if code != 200 {
|
|
t.Fatalf("issuance rate = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("JSONMetrics", func(t *testing.T) {
|
|
var m qaMetrics
|
|
c.getJSON(t, "/api/v1/metrics", &m)
|
|
if m.Uptime <= 0 {
|
|
t.Fatalf("uptime = %f, want > 0", m.Uptime)
|
|
}
|
|
if len(m.Gauge) == 0 {
|
|
t.Fatal("no gauge metrics")
|
|
}
|
|
})
|
|
|
|
t.Run("Prometheus_ContentType", func(t *testing.T) {
|
|
resp, err := c.get("/api/v1/metrics/prometheus")
|
|
if err != nil {
|
|
t.Fatalf("GET prometheus: %v", err)
|
|
}
|
|
ct := resp.Header.Get("Content-Type")
|
|
resp.Body.Close()
|
|
if !strings.Contains(ct, "text/plain") {
|
|
t.Fatalf("prometheus content-type = %q, want text/plain", ct)
|
|
}
|
|
})
|
|
|
|
t.Run("Prometheus_HasMetrics", func(t *testing.T) {
|
|
_, body := c.bodyStr(t, "GET", "/api/v1/metrics/prometheus", "")
|
|
for _, metric := range []string{
|
|
"certctl_certificate_total",
|
|
"certctl_agent_total",
|
|
"certctl_job_pending",
|
|
"certctl_uptime_seconds",
|
|
} {
|
|
if !strings.Contains(body, metric) {
|
|
t.Errorf("prometheus output missing %s", metric)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 31: Notifications
|
|
// ===================================================================
|
|
t.Run("Part31_Notifications", func(t *testing.T) {
|
|
t.Run("ListNotifications", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/notifications", "")
|
|
if code != 200 {
|
|
t.Fatalf("list notifications = %d", code)
|
|
}
|
|
})
|
|
|
|
t.Run("GetNonexistent", func(t *testing.T) {
|
|
code, _ := c.statusCode("GET", "/api/v1/notifications/nonexistent", "")
|
|
if code != 404 {
|
|
t.Fatalf("nonexistent notification = %d, want 404", code)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 32: Audit Trail
|
|
// ===================================================================
|
|
t.Run("Part32_Audit", func(t *testing.T) {
|
|
t.Run("ListEvents", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/audit", &pr)
|
|
if pr.Total < 10 {
|
|
t.Fatalf("audit events = %d, want >= 10", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("Immutability_NoPUT", func(t *testing.T) {
|
|
code, _ := c.statusCode("PUT", "/api/v1/audit/any-event-id", `{"action":"hack"}`)
|
|
if code == 200 {
|
|
t.Fatal("PUT /events should not return 200 — audit trail must be immutable")
|
|
}
|
|
})
|
|
|
|
t.Run("Immutability_NoDELETE", func(t *testing.T) {
|
|
code, _ := c.statusCode("DELETE", "/api/v1/audit/any-event-id", "")
|
|
if code == 200 || code == 204 {
|
|
t.Fatal("DELETE /events should not succeed — audit trail must be immutable")
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 33: Background Scheduler (log-based checks)
|
|
// ===================================================================
|
|
t.Run("Part33_Scheduler", func(t *testing.T) {
|
|
t.Skip("Scheduler tests are timing-dependent — verify via Docker logs manually")
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 34: Structured Logging
|
|
// ===================================================================
|
|
t.Run("Part34_Logging", func(t *testing.T) {
|
|
t.Skip("Requires Docker log inspection — manual test")
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 35: GUI Testing
|
|
// ===================================================================
|
|
t.Run("Part35_GUI", func(t *testing.T) {
|
|
t.Skip("Requires browser — manual test")
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 36-37: Issuer Catalog, Frontend Audit
|
|
// ===================================================================
|
|
t.Run("Part36_IssuerCatalog", func(t *testing.T) {
|
|
t.Skip("Requires browser — manual test")
|
|
})
|
|
|
|
t.Run("Part37_FrontendAudit", func(t *testing.T) {
|
|
t.Skip("Requires browser — manual test")
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 38: Error Handling
|
|
// ===================================================================
|
|
t.Run("Part38_ErrorHandling", func(t *testing.T) {
|
|
t.Run("MalformedJSON", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates", "this is not json")
|
|
if code != 400 {
|
|
t.Fatalf("malformed JSON = %d, want 400", code)
|
|
}
|
|
})
|
|
|
|
t.Run("MissingRequiredField", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates", `{"id":"mc-qa-noCN"}`)
|
|
if code != 400 {
|
|
t.Fatalf("missing CN = %d, want 400", code)
|
|
}
|
|
})
|
|
|
|
t.Run("MethodNotAllowed", func(t *testing.T) {
|
|
code, _ := c.statusCode("PATCH", "/api/v1/certificates", "")
|
|
if code != 405 {
|
|
t.Logf("PATCH /certificates = %d (server may not distinguish 405 from 404)", code)
|
|
}
|
|
})
|
|
|
|
t.Run("UTF8InCommonName", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "POST", "/api/v1/certificates", `{
|
|
"id":"mc-qa-utf8","common_name":"日本語.example.com","issuer_id":"iss-local"
|
|
}`)
|
|
if code == 500 {
|
|
t.Fatal("server crashed on UTF-8 common name")
|
|
}
|
|
t.Cleanup(func() { c.delete("/api/v1/certificates/mc-qa-utf8") })
|
|
})
|
|
|
|
t.Run("EmptyBody", func(t *testing.T) {
|
|
code, _ := c.statusCode("POST", "/api/v1/certificates", "")
|
|
if code == 500 {
|
|
t.Fatal("server crashed on empty body")
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 39: Performance Spot Checks
|
|
// ===================================================================
|
|
t.Run("Part39_Performance", func(t *testing.T) {
|
|
t.Run("ListCerts_Under200ms", func(t *testing.T) {
|
|
d, code, err := c.timedGet("/api/v1/certificates?per_page=15")
|
|
if err != nil {
|
|
t.Fatalf("request: %v", err)
|
|
}
|
|
if code != 200 {
|
|
t.Fatalf("status = %d", code)
|
|
}
|
|
if d > 200*time.Millisecond {
|
|
t.Fatalf("took %v, want < 200ms", d)
|
|
}
|
|
})
|
|
|
|
t.Run("StatsSummary_Under500ms", func(t *testing.T) {
|
|
d, code, _ := c.timedGet("/api/v1/stats/summary")
|
|
if code != 200 {
|
|
t.Fatalf("status = %d", code)
|
|
}
|
|
if d > 500*time.Millisecond {
|
|
t.Fatalf("took %v, want < 500ms", d)
|
|
}
|
|
})
|
|
|
|
t.Run("Metrics_Under200ms", func(t *testing.T) {
|
|
d, code, _ := c.timedGet("/api/v1/metrics")
|
|
if code != 200 {
|
|
t.Fatalf("status = %d", code)
|
|
}
|
|
if d > 200*time.Millisecond {
|
|
t.Fatalf("took %v, want < 200ms", d)
|
|
}
|
|
})
|
|
|
|
t.Run("Prometheus_Under300ms", func(t *testing.T) {
|
|
d, code, _ := c.timedGet("/api/v1/metrics/prometheus")
|
|
if code != 200 {
|
|
t.Fatalf("status = %d", code)
|
|
}
|
|
if d > 300*time.Millisecond {
|
|
t.Fatalf("took %v, want < 300ms", d)
|
|
}
|
|
})
|
|
|
|
t.Run("AuditTrail_Under500ms", func(t *testing.T) {
|
|
d, code, _ := c.timedGet("/api/v1/audit?per_page=50")
|
|
if code != 200 {
|
|
t.Fatalf("status = %d", code)
|
|
}
|
|
if d > 500*time.Millisecond {
|
|
t.Fatalf("took %v, want < 500ms", d)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 40: Documentation Verification (source checks)
|
|
// ===================================================================
|
|
t.Run("Part40_Docs", func(t *testing.T) {
|
|
t.Run("README_Exists", func(t *testing.T) {
|
|
fileExists(t, "README.md")
|
|
})
|
|
|
|
t.Run("Quickstart_Exists", func(t *testing.T) {
|
|
fileExists(t, "docs/quickstart.md")
|
|
})
|
|
|
|
t.Run("Architecture_Exists", func(t *testing.T) {
|
|
fileExists(t, "docs/architecture.md")
|
|
})
|
|
|
|
t.Run("Connectors_Exists", func(t *testing.T) {
|
|
fileExists(t, "docs/connectors.md")
|
|
})
|
|
|
|
t.Run("Compliance_Exists", func(t *testing.T) {
|
|
fileExists(t, "docs/compliance.md")
|
|
})
|
|
|
|
t.Run("MigrationGuides_Exist", func(t *testing.T) {
|
|
for _, guide := range []string{
|
|
"docs/migrate-from-certbot.md",
|
|
"docs/migrate-from-acmesh.md",
|
|
"docs/certctl-for-cert-manager-users.md",
|
|
} {
|
|
fileExists(t, guide)
|
|
}
|
|
})
|
|
|
|
t.Run("IssuerTypes_InDocs", func(t *testing.T) {
|
|
data, err := os.ReadFile(repoFile("docs/connectors.md"))
|
|
if err != nil {
|
|
t.Fatalf("read connectors.md: %v", err)
|
|
}
|
|
doc := string(data)
|
|
for _, typ := range []string{"ACME", "Vault", "step-ca", "DigiCert", "Sectigo", "Google CAS", "Local CA", "OpenSSL"} {
|
|
if !strings.Contains(doc, typ) {
|
|
t.Errorf("connectors.md missing issuer type: %s", typ)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("TargetTypes_InDocs", func(t *testing.T) {
|
|
data, err := os.ReadFile(repoFile("docs/connectors.md"))
|
|
if err != nil {
|
|
t.Fatalf("read connectors.md: %v", err)
|
|
}
|
|
doc := string(data)
|
|
for _, typ := range []string{"NGINX", "Apache", "HAProxy", "Traefik", "Caddy", "Envoy", "F5", "IIS", "SSH", "Postfix", "Java Keystore"} {
|
|
if !strings.Contains(doc, typ) {
|
|
t.Errorf("connectors.md missing target type: %s", typ)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 41: Regression Tests
|
|
// ===================================================================
|
|
t.Run("Part41_Regression", func(t *testing.T) {
|
|
t.Run("DELETE_Returns204", func(t *testing.T) {
|
|
c.post("/api/v1/targets", `{"id":"tgt-qa-regr","name":"Regression","type":"NGINX","config":{}}`)
|
|
code, _ := c.statusCode("DELETE", "/api/v1/targets/tgt-qa-regr", "")
|
|
if code != 204 {
|
|
t.Fatalf("DELETE target = %d, want 204", code)
|
|
}
|
|
})
|
|
|
|
t.Run("PerPage_MaxFallback", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/certificates?per_page=9999", &pr)
|
|
if pr.PerPage != 50 {
|
|
t.Fatalf("per_page = %d, want 50 (default fallback)", pr.PerPage)
|
|
}
|
|
})
|
|
|
|
t.Run("SeedNetworkScanTargets", func(t *testing.T) {
|
|
var pr qaPagedResponse
|
|
c.getJSON(t, "/api/v1/network-scan-targets", &pr)
|
|
if pr.Total < 3 {
|
|
t.Fatalf("scan targets = %d, want >= 3", pr.Total)
|
|
}
|
|
})
|
|
|
|
t.Run("NoErrors_Is_With_New", func(t *testing.T) {
|
|
// Verify no test files use the broken errors.Is(err, errors.New(...)) pattern
|
|
data, err := os.ReadFile(repoFile("internal/service"))
|
|
if err != nil {
|
|
// Can't read a directory, use filepath.Walk
|
|
var found int
|
|
filepath.Walk(repoFile("internal/service"), func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || !strings.HasSuffix(path, "_test.go") {
|
|
return nil
|
|
}
|
|
content, _ := os.ReadFile(path)
|
|
if strings.Contains(string(content), "errors.Is") && strings.Contains(string(content), "errors.New") {
|
|
// Check if they're on the same line
|
|
for _, line := range strings.Split(string(content), "\n") {
|
|
if strings.Contains(line, "errors.Is") && strings.Contains(line, "errors.New") {
|
|
found++
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
_ = data
|
|
if found > 0 {
|
|
t.Fatalf("found %d instances of errors.Is(err, errors.New(...)) anti-pattern", found)
|
|
}
|
|
return
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 42: Envoy Target Connector (source checks)
|
|
// ===================================================================
|
|
t.Run("Part42_Envoy", func(t *testing.T) {
|
|
t.Run("DomainType", func(t *testing.T) {
|
|
fileContains(t, "internal/domain/connector.go", "TargetTypeEnvoy")
|
|
})
|
|
|
|
t.Run("ConnectorExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/envoy/envoy.go")
|
|
})
|
|
|
|
t.Run("TestFileExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/envoy/envoy_test.go")
|
|
})
|
|
|
|
t.Run("InOpenAPI", func(t *testing.T) {
|
|
fileContains(t, "api/openapi.yaml", "Envoy")
|
|
})
|
|
|
|
t.Run("AgentDispatch", func(t *testing.T) {
|
|
fileContains(t, "cmd/agent/main.go", "envoy")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 43: Postfix & Dovecot
|
|
// ===================================================================
|
|
t.Run("Part43_PostfixDovecot", func(t *testing.T) {
|
|
t.Run("DomainTypes", func(t *testing.T) {
|
|
fileContains(t, "internal/domain/connector.go", "TargetTypePostfix")
|
|
fileContains(t, "internal/domain/connector.go", "TargetTypeDovecot")
|
|
})
|
|
|
|
t.Run("ConnectorExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/postfix/postfix.go")
|
|
})
|
|
|
|
t.Run("InOpenAPI", func(t *testing.T) {
|
|
fileContains(t, "api/openapi.yaml", "Postfix")
|
|
fileContains(t, "api/openapi.yaml", "Dovecot")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 44: SSH Target Connector
|
|
// ===================================================================
|
|
t.Run("Part44_SSH", func(t *testing.T) {
|
|
t.Run("DomainType", func(t *testing.T) {
|
|
fileContains(t, "internal/domain/connector.go", "TargetTypeSSH")
|
|
})
|
|
|
|
t.Run("ConnectorExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/ssh/ssh.go")
|
|
})
|
|
|
|
t.Run("AgentDispatch", func(t *testing.T) {
|
|
fileContains(t, "cmd/agent/main.go", "sshconn")
|
|
})
|
|
|
|
t.Run("InOpenAPI", func(t *testing.T) {
|
|
fileContains(t, "api/openapi.yaml", "SSH")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 45: Windows Certificate Store
|
|
// ===================================================================
|
|
t.Run("Part45_WinCertStore", func(t *testing.T) {
|
|
t.Run("DomainType", func(t *testing.T) {
|
|
fileContains(t, "internal/domain/connector.go", "TargetTypeWinCertStore")
|
|
})
|
|
|
|
t.Run("ConnectorExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/wincertstore/wincertstore.go")
|
|
})
|
|
|
|
t.Run("SharedCertutil", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/certutil/certutil.go")
|
|
fileExists(t, "internal/connector/target/certutil/certutil_test.go")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 46: Java Keystore
|
|
// ===================================================================
|
|
t.Run("Part46_JavaKeystore", func(t *testing.T) {
|
|
t.Run("DomainType", func(t *testing.T) {
|
|
fileContains(t, "internal/domain/connector.go", "TargetTypeJavaKeystore")
|
|
})
|
|
|
|
t.Run("ConnectorExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/javakeystore/javakeystore.go")
|
|
})
|
|
|
|
t.Run("InOpenAPI", func(t *testing.T) {
|
|
fileContains(t, "api/openapi.yaml", "JavaKeystore")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 47: Certificate Digest Email
|
|
// ===================================================================
|
|
t.Run("Part47_Digest", func(t *testing.T) {
|
|
t.Run("PreviewEndpoint", func(t *testing.T) {
|
|
code, _ := c.bodyStr(t, "GET", "/api/v1/digest/preview", "")
|
|
// 200 if SMTP configured, 503 if not
|
|
if code != 200 && code != 503 {
|
|
t.Fatalf("digest preview = %d, want 200 or 503", code)
|
|
}
|
|
})
|
|
|
|
t.Run("ServiceExists", func(t *testing.T) {
|
|
fileExists(t, "internal/service/digest.go")
|
|
})
|
|
|
|
t.Run("AdapterExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/notifier/email/adapter.go")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 48: Dynamic Issuer Configuration
|
|
// ===================================================================
|
|
t.Run("Part48_DynamicIssuers", func(t *testing.T) {
|
|
t.Run("CryptoPackage", func(t *testing.T) {
|
|
fileExists(t, "internal/crypto/crypto.go")
|
|
})
|
|
|
|
t.Run("CreateIssuerViaAPI", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "POST", "/api/v1/issuers", `{
|
|
"name":"QA Dynamic ACME","type":"ACME",
|
|
"config":{"directory_url":"https://acme-staging-v02.api.letsencrypt.org/directory","email":"qa@example.com"}
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create dynamic issuer = %d, body: %s", code, body)
|
|
}
|
|
// Extract ID for cleanup
|
|
var resp map[string]interface{}
|
|
json.Unmarshal([]byte(body), &resp)
|
|
if id, ok := resp["id"].(string); ok {
|
|
t.Cleanup(func() { c.delete("/api/v1/issuers/" + id) })
|
|
}
|
|
})
|
|
|
|
t.Run("ConfigRedacted", func(t *testing.T) {
|
|
// Check that sensitive fields are masked in list responses
|
|
_, body := c.bodyStr(t, "GET", "/api/v1/issuers", "")
|
|
// If vault token or api_key appears unmasked, it's a security issue
|
|
if strings.Contains(body, "s.") && strings.Contains(body, "vault_token") {
|
|
// Heuristic — real vault tokens start with "s."
|
|
t.Log("WARNING: Vault token may be exposed in API response")
|
|
}
|
|
})
|
|
|
|
t.Run("Migration_Exists", func(t *testing.T) {
|
|
fileExists(t, "migrations/000009_issuer_config.up.sql")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 49: Dynamic Target Configuration
|
|
// ===================================================================
|
|
t.Run("Part49_DynamicTargets", func(t *testing.T) {
|
|
t.Run("CreateTargetViaAPI", func(t *testing.T) {
|
|
code, body := c.bodyStr(t, "POST", "/api/v1/targets", `{
|
|
"name":"QA Dynamic NGINX","type":"NGINX",
|
|
"config":{"cert_path":"/etc/ssl/cert.pem","key_path":"/etc/ssl/key.pem","reload_command":"nginx -s reload"}
|
|
}`)
|
|
if code != 201 && code != 200 {
|
|
t.Fatalf("create dynamic target = %d, body: %s", code, body)
|
|
}
|
|
var resp map[string]interface{}
|
|
json.Unmarshal([]byte(body), &resp)
|
|
if id, ok := resp["id"].(string); ok {
|
|
t.Cleanup(func() { c.delete("/api/v1/targets/" + id) })
|
|
}
|
|
})
|
|
|
|
t.Run("Migration_Exists", func(t *testing.T) {
|
|
fileExists(t, "migrations/000010_target_config.up.sql")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 50: Onboarding Wizard
|
|
// ===================================================================
|
|
t.Run("Part50_Onboarding", func(t *testing.T) {
|
|
t.Run("WizardComponent_Exists", func(t *testing.T) {
|
|
fileExists(t, "web/src/pages/OnboardingWizard.tsx")
|
|
})
|
|
|
|
t.Run("DockerCompose_Split", func(t *testing.T) {
|
|
// Clean compose should NOT reference seed_demo
|
|
data, _ := os.ReadFile(repoFile("deploy/docker-compose.yml"))
|
|
if strings.Contains(string(data), "seed_demo") {
|
|
t.Fatal("docker-compose.yml should not reference seed_demo.sql")
|
|
}
|
|
// Demo override SHOULD reference seed_demo
|
|
data, _ = os.ReadFile(repoFile("deploy/docker-compose.demo.yml"))
|
|
if !strings.Contains(string(data), "seed_demo") {
|
|
t.Fatal("docker-compose.demo.yml should reference seed_demo.sql")
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 51: ACME Profile Selection
|
|
// ===================================================================
|
|
t.Run("Part51_ACMEProfiles", func(t *testing.T) {
|
|
t.Run("ProfileModule_Exists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/issuer/acme/profile.go")
|
|
fileExists(t, "internal/connector/issuer/acme/profile_test.go")
|
|
})
|
|
|
|
t.Run("ProfileConfig_InFrontend", func(t *testing.T) {
|
|
fileContains(t, "web/src/config/issuerTypes.ts", "profile")
|
|
})
|
|
|
|
t.Run("ARI_RFC9773_NoOldRefs", func(t *testing.T) {
|
|
// Verify no remaining references to old RFC 9702
|
|
files := []string{
|
|
"internal/connector/issuer/acme/ari.go",
|
|
"internal/domain/ari.go",
|
|
"internal/service/renewal.go",
|
|
}
|
|
for _, f := range files {
|
|
data, err := os.ReadFile(repoFile(f))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if strings.Contains(string(data), "9702") {
|
|
t.Errorf("%s still references RFC 9702 (should be 9773)", f)
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
// Part 52: Helm Chart
|
|
// ===================================================================
|
|
t.Run("Part52_HelmChart", func(t *testing.T) {
|
|
t.Run("ChartYAML_Exists", func(t *testing.T) {
|
|
fileExists(t, "deploy/helm/certctl/Chart.yaml")
|
|
})
|
|
|
|
t.Run("ValuesYAML_Exists", func(t *testing.T) {
|
|
fileExists(t, "deploy/helm/certctl/values.yaml")
|
|
})
|
|
|
|
t.Run("Templates_Exist", func(t *testing.T) {
|
|
for _, tmpl := range []string{
|
|
"deploy/helm/certctl/templates/server-deployment.yaml",
|
|
"deploy/helm/certctl/templates/server-service.yaml",
|
|
"deploy/helm/certctl/templates/postgres-statefulset.yaml",
|
|
"deploy/helm/certctl/templates/agent-daemonset.yaml",
|
|
} {
|
|
fileExists(t, tmpl)
|
|
}
|
|
})
|
|
|
|
t.Run("SecurityContext_InTemplates", func(t *testing.T) {
|
|
fileContains(t, "deploy/helm/certctl/templates/server-deployment.yaml", "securityContext")
|
|
})
|
|
|
|
t.Run("HealthProbes_InTemplates", func(t *testing.T) {
|
|
fileContains(t, "deploy/helm/certctl/templates/server-deployment.yaml", "livenessProbe")
|
|
fileContains(t, "deploy/helm/certctl/templates/server-deployment.yaml", "readinessProbe")
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
t.Run("Part53_KubernetesSecrets", func(t *testing.T) {
|
|
t.Run("ConnectorPackageExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/k8ssecret/k8ssecret.go")
|
|
})
|
|
|
|
t.Run("TestFileExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/target/k8ssecret/k8ssecret_test.go")
|
|
})
|
|
|
|
t.Run("DomainTypeRegistered", func(t *testing.T) {
|
|
fileContains(t, "internal/domain/connector.go", `TargetTypeKubernetesSecrets`)
|
|
})
|
|
|
|
t.Run("ServiceValidationEntry", func(t *testing.T) {
|
|
fileContains(t, "internal/service/target.go", `TargetTypeKubernetesSecrets`)
|
|
})
|
|
|
|
t.Run("AgentDispatchCase", func(t *testing.T) {
|
|
fileContains(t, "cmd/agent/main.go", `"KubernetesSecrets"`)
|
|
})
|
|
|
|
t.Run("FrontendTypeLabel", func(t *testing.T) {
|
|
fileContains(t, "web/src/pages/TargetsPage.tsx", `KubernetesSecrets`)
|
|
})
|
|
|
|
t.Run("OpenAPIEnum", func(t *testing.T) {
|
|
fileContains(t, "api/openapi.yaml", `KubernetesSecrets`)
|
|
})
|
|
|
|
t.Run("HelmRBAC", func(t *testing.T) {
|
|
fileContains(t, "deploy/helm/certctl/templates/serviceaccount.yaml", `secrets`)
|
|
})
|
|
})
|
|
|
|
// ===================================================================
|
|
t.Run("Part54_AWSACMPCA", func(t *testing.T) {
|
|
t.Run("ConnectorPackageExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/issuer/awsacmpca/awsacmpca.go")
|
|
})
|
|
|
|
t.Run("TestFileExists", func(t *testing.T) {
|
|
fileExists(t, "internal/connector/issuer/awsacmpca/awsacmpca_test.go")
|
|
})
|
|
|
|
t.Run("DomainTypeRegistered", func(t *testing.T) {
|
|
fileContains(t, "internal/domain/connector.go", `IssuerTypeAWSACMPCA`)
|
|
})
|
|
|
|
t.Run("ServiceValidationEntry", func(t *testing.T) {
|
|
fileContains(t, "internal/service/issuer.go", `IssuerTypeAWSACMPCA`)
|
|
})
|
|
|
|
t.Run("FactoryCase", func(t *testing.T) {
|
|
fileContains(t, "internal/connector/issuerfactory/factory.go", `"AWSACMPCA"`)
|
|
})
|
|
|
|
t.Run("ConfigStruct", func(t *testing.T) {
|
|
fileContains(t, "internal/config/config.go", `AWSACMPCAConfig`)
|
|
})
|
|
|
|
t.Run("EnvVarSeed", func(t *testing.T) {
|
|
fileContains(t, "internal/service/issuer.go", `iss-awsacmpca`)
|
|
})
|
|
|
|
t.Run("FrontendIssuerType", func(t *testing.T) {
|
|
fileContains(t, "web/src/config/issuerTypes.ts", `AWSACMPCA`)
|
|
})
|
|
|
|
t.Run("OpenAPIEnum", func(t *testing.T) {
|
|
fileContains(t, "api/openapi.yaml", `AWSACMPCA`)
|
|
})
|
|
|
|
t.Run("SeedDemoData", func(t *testing.T) {
|
|
fileContains(t, "migrations/seed_demo.sql", `iss-awsacmpca`)
|
|
})
|
|
})
|
|
}
|
|
|
|
// Note: uses Go 1.21+ built-in min() — no custom definition needed.
|