Files
certctl/internal/integration/e2e_test.go
T
Shankar Reddy 45361477ed Unify API auth + RFC-compliant CRL/OCSP (M-002 + M-003 + M-006, auto-closes M-001)
Closes the remaining P1 gaps from coverage-gap-audit.md (M-001/M-002/M-003/M-006)
on top of the C-001/C-002 ownership + agent-FK contract fixes landed in
5c01c7f. The work lands as a single commit spanning server, docs, tests,
and the React client.

M-002 — Named API keys with per-key actor propagation
  * Migration 000014 adds the 'api_keys' table (id, name, hash,
    principal, role, created_at, last_used_at, disabled_at) so every
    credential carries an identifiable principal instead of the
    opaque 'anonymous'/'api-key' sentinel.
  * Auth middleware now rotates through configured keys, performs
    constant-time hash comparison, stamps 'last_used_at', and emits
    an actor struct via contextWithActor(). The audit middleware,
    bulk-revocation handler, approval handlers, and MCP tool layer
    now read the principal off the context and persist it on every
    audit_events row.
  * Regression coverage:
      - internal/api/middleware/audit_test.go — actor propagation,
        principal redaction for disabled keys, anonymous fallback for
        unauthenticated endpoints.
      - internal/api/handler/bulk_revocation_handler_test.go,
        job_handler_test.go — principal-on-audit assertions.

M-003 — Authorization gates (Phase B)
  * Approval handler rejects self-approval / self-rejection with 403
    when the actor principal equals the job's requested_by field.
  * Bulk revocation is gated behind the 'admin' role; operators and
    viewers receive 403.
  * Regression coverage:
      - internal/service/job_test.go — TestApproveJob_NotSelf,
        TestRejectJob_NotSelf.
      - internal/api/handler/bulk_revocation_handler_test.go —
        TestBulkRevoke_RequiresAdmin, TestBulkRevoke_AdminSucceeds.

M-006 — RFC-compliant CRL/OCSP on the unauthenticated .well-known mux
  * Per RFC 8615, relying parties cannot reasonably be asked to
    authenticate against the issuing certctl instance to retrieve
    revocation material. CRL and OCSP move off the authenticated
    '/api/v1/crl*' and '/api/v1/ocsp/*' paths onto:
        GET /.well-known/pki/crl/{issuer_id}
            Content-Type: application/pkix-crl   (RFC 5280 §5)
        GET /.well-known/pki/ocsp/{issuer_id}/{serial}
            Content-Type: application/ocsp-response  (RFC 6960)
  * Non-standard JSON CRL shape is removed; only DER is served.
  * Short-lived certificate exemption (profile TTL < 1h → skip
    CRL/OCSP) is preserved; the response simply omits the serial.
  * Routes are registered on the unauthenticated 'finalHandler' mux
    in cmd/server/main.go alongside EST ('/.well-known/est/*') and
    SCEP ('/scep'). Legacy authenticated paths return 404.
  * Regression coverage:
      - internal/api/handler/certificate_handler_test.go — content
        type, DER parseability, 404 for unknown issuer.
      - internal/api/handler/adversarial_path_test.go — unauthenticated
        access asserted for CRL, OCSP, EST, SCEP.
      - internal/api/router/router_test.go — route-table assertion
        that '.well-known/pki/*', '.well-known/est/*', and '/scep' are
        mounted on the unauthenticated branch.

M-001 — Auto-closed by M-002
  EST and SCEP were already registered on the unauthenticated
  'finalHandler' mux; the router comment at
  internal/api/router/router.go:247 now matches reality. The
  adversarial-path tests above lock the behavior in.

Verification (all gates green):
  * go vet ./...                                           — clean
  * go build ./...                                         — ok
  * go test -short ./... (55+ packages)                    — all pass
  * web/ : npm test (225 Vitest tests)                     — all pass
  * web/ : npx tsc --noEmit                                — clean
  * grep sweep for '/api/v1/(crl|ocsp)' — 13 surviving hits,
    all intentional M-006 tombstone/relocation comments.

Documentation:
  * coverage-gap-audit.md — status flips M-001/M-002/M-003/M-006 →
    Fixed, with per-finding resolution paragraphs citing regression
    test IDs. (Audit file lives outside this repo; see cowork root.)
  * CLAUDE.md Project Status line updated with the auth-unification
    closure note.
  * docs/features.md, docs/architecture.md, docs/quickstart.md,
    docs/concepts.md, docs/connectors.md, docs/test-env.md,
    docs/testing-guide.md, docs/compliance-*.md, docs/demo-advanced.md
    — refreshed for the new '.well-known/pki/*' namespace and named
    API keys.
  * api/openapi.yaml — documents the new unauthenticated endpoints
    and removes the legacy '/api/v1/crl*' + '/api/v1/ocsp/*' paths.

.gitignore: adds '/.gocache/' and '/.gomodcache/' for the session-
scoped Go caches so they never enter the tree.
2026-04-18 18:17:41 +00:00

1177 lines
38 KiB
Go

package integration
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/shankar0123/certctl/internal/domain"
)
// TestStatsAndMetricsEndpoints exercises the M14 observability endpoints end-to-end.
func TestStatsAndMetricsEndpoints(t *testing.T) {
server, _, _, _ := setupTestServer(t)
t.Run("GetHealth", func(t *testing.T) {
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var body map[string]string
json.NewDecoder(resp.Body).Decode(&body)
if body["status"] != "healthy" {
t.Errorf("expected status=healthy, got %s", body["status"])
}
})
t.Run("GetReady", func(t *testing.T) {
resp, err := http.Get(server.URL + "/ready")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("GetMetrics", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/metrics")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var metrics map[string]interface{}
json.NewDecoder(resp.Body).Decode(&metrics)
if metrics["gauge"] == nil {
t.Error("expected gauge in metrics response")
}
if metrics["counter"] == nil {
t.Error("expected counter in metrics response")
}
if metrics["uptime"] == nil {
t.Error("expected uptime in metrics response")
}
})
t.Run("GetStatsSummary", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/stats/summary")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("GetCertificatesByStatus", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/stats/certificates-by-status")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("GetExpirationTimeline", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/stats/expiration-timeline?days=90")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("GetExpirationTimeline_DefaultDays", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/stats/expiration-timeline")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("GetJobTrends", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/stats/job-trends?days=30")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("GetIssuanceRate", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/stats/issuance-rate?days=30")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
}
// TestCrossResourceWorkflow exercises a multi-step workflow spanning certificates,
// policies, agents, jobs, audit trail, and notifications — verifying data flows
// correctly across service boundaries.
func TestCrossResourceWorkflow(t *testing.T) {
server, certRepo, jobRepo, agentRepo := setupTestServer(t)
// Step 1: Create a policy rule
var policyID string
t.Run("CreatePolicy", func(t *testing.T) {
payload := map[string]interface{}{
"name": "Allowed Domains Policy",
"type": "AllowedDomains",
"severity": "Error",
"config": json.RawMessage(`{"domains": ["example.com", "*.example.com"]}`),
"description": "Restrict issuance to example.com domains",
}
body, _ := json.Marshal(payload)
resp, err := http.Post(server.URL+"/api/v1/policies", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var rule domain.PolicyRule
json.NewDecoder(resp.Body).Decode(&rule)
policyID = rule.ID
if policyID == "" {
t.Fatal("expected policy ID")
}
t.Logf("Created policy: %s", policyID)
})
// Step 2: Create a certificate
var certID string
t.Run("CreateCertificate", func(t *testing.T) {
now := time.Now()
payload := map[string]interface{}{
"name": "Workflow Test Cert",
"common_name": "workflow.example.com",
"sans": []string{"www.workflow.example.com"},
"environment": "staging",
"owner_id": "owner-ops",
"team_id": "team-platform",
"issuer_id": "iss-local",
"target_ids": []string{},
"renewal_policy_id": "policy-standard",
"status": "Pending",
"expires_at": now.AddDate(0, 3, 0),
"tags": map[string]string{"team": "platform"},
}
body, _ := json.Marshal(payload)
resp, err := http.Post(server.URL+"/api/v1/certificates", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var cert domain.ManagedCertificate
json.NewDecoder(resp.Body).Decode(&cert)
certID = cert.ID
t.Logf("Created certificate: %s", certID)
})
// Step 3: Register an agent
var agentID string
t.Run("RegisterAgent", func(t *testing.T) {
payload := map[string]string{"name": "workflow-agent", "hostname": "workflow-host-01"}
body, _ := json.Marshal(payload)
resp, err := http.Post(server.URL+"/api/v1/agents", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var agent domain.Agent
json.NewDecoder(resp.Body).Decode(&agent)
agentID = agent.ID
t.Logf("Registered agent: %s", agentID)
})
// Step 4: Trigger renewal
t.Run("TriggerRenewal", func(t *testing.T) {
resp, err := http.Post(server.URL+"/api/v1/certificates/"+certID+"/renew", "application/json", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 202, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
// Step 5: Verify jobs were created
t.Run("VerifyJobsCreated", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/jobs?page=1&per_page=50")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
// data may be null (nil) if no jobs exist, or an array
if data, ok := respBody["data"].([]interface{}); ok && len(data) > 0 {
t.Logf("Found %d jobs after renewal trigger", len(data))
} else {
t.Log("No jobs found after renewal trigger (expected — mock TriggerRenewal is async/no-op)")
}
})
// Step 6: Agent heartbeat with metadata
t.Run("AgentHeartbeatWithMetadata", func(t *testing.T) {
payload := map[string]interface{}{
"os": "linux",
"architecture": "amd64",
"ip_address": "10.0.1.50",
"version": "1.0.0",
}
body, _ := json.Marshal(payload)
resp, err := http.Post(server.URL+"/api/v1/agents/"+agentID+"/heartbeat", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
// Verify metadata was stored
agent, ok := agentRepo.agents[agentID]
if !ok {
t.Fatal("agent not found in repo after heartbeat")
}
if agent.LastHeartbeatAt == nil {
t.Error("expected heartbeat timestamp to be set")
}
})
// Step 7: Add a version to the cert so revocation works
t.Run("AddCertVersion", func(t *testing.T) {
now := time.Now()
certRepo.versions[certID] = []*domain.CertificateVersion{
{
ID: "cv-workflow-1",
CertificateID: certID,
SerialNumber: "WORKFLOW-SERIAL-001",
NotBefore: now,
NotAfter: now.AddDate(0, 3, 0),
CreatedAt: now,
},
}
// Update cert status to Active for revocation
if cert, ok := certRepo.certs[certID]; ok {
cert.Status = domain.CertificateStatusActive
}
})
// Step 8: Revoke the certificate
t.Run("RevokeCertificate", func(t *testing.T) {
body := bytes.NewBufferString(`{"reason":"cessationOfOperation"}`)
resp, err := http.Post(server.URL+"/api/v1/certificates/"+certID+"/revoke", "application/json", body)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
// Verify cert status changed to Revoked
cert := certRepo.certs[certID]
if cert.Status != domain.CertificateStatusRevoked {
t.Errorf("expected Revoked status, got %s", cert.Status)
}
})
// Step 9: Verify revoked cert cannot be renewed
t.Run("CannotRenewRevoked", func(t *testing.T) {
resp, err := http.Post(server.URL+"/api/v1/certificates/"+certID+"/renew", "application/json", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Revoked cert should not accept renewal (expect error status)
if resp.StatusCode == http.StatusAccepted {
t.Log("Warning: revoked cert accepted renewal — may need business logic enforcement")
}
})
// Step 10: Verify audit trail accumulated events
t.Run("AuditTrailAccumulated", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/audit?page=1&per_page=100")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
data, ok := respBody["data"].([]interface{})
if !ok {
t.Fatal("expected data array")
}
// We should have at least cert_created, agent_registered, renewal_triggered, cert_revoked
if len(data) < 3 {
t.Logf("Warning: expected at least 3 audit events, got %d", len(data))
}
t.Logf("Total audit events from workflow: %d", len(data))
// Verify event types
eventTypes := make(map[string]int)
for _, evt := range data {
if eventMap, ok := evt.(map[string]interface{}); ok {
if action, ok := eventMap["action"].(string); ok {
eventTypes[action]++
}
}
}
t.Logf("Audit event types: %v", eventTypes)
})
// Summary
t.Run("WorkflowSummary", func(t *testing.T) {
certCount := len(certRepo.certs)
jobCount := len(jobRepo.jobs)
agentCount := len(agentRepo.agents)
t.Logf("Cross-resource workflow complete: %d certs, %d jobs, %d agents", certCount, jobCount, agentCount)
})
}
// TestJobApprovalWorkflow exercises the interactive approval flow (M11b).
func TestJobApprovalWorkflow(t *testing.T) {
server, _, jobRepo, _ := setupTestServer(t)
// Seed a job in AwaitingApproval state
jobID := "job-approval-test-1"
jobRepo.jobs[jobID] = &domain.Job{
ID: jobID,
CertificateID: "mc-test",
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingApproval,
MaxAttempts: 3,
Attempts: 0,
CreatedAt: time.Now(),
}
t.Run("ApproveJob_Success", func(t *testing.T) {
payload := map[string]string{"reason": "Approved by ops team"}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/"+jobID+"/approve", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
// Verify job moved to Pending
job := jobRepo.jobs[jobID]
if job.Status != domain.JobStatusPending {
t.Errorf("expected Pending after approval, got %s", job.Status)
}
})
// Seed another job for rejection
rejectJobID := "job-reject-test-1"
jobRepo.jobs[rejectJobID] = &domain.Job{
ID: rejectJobID,
CertificateID: "mc-test",
Type: domain.JobTypeRenewal,
Status: domain.JobStatusAwaitingApproval,
MaxAttempts: 3,
Attempts: 0,
CreatedAt: time.Now(),
}
t.Run("RejectJob_Success", func(t *testing.T) {
payload := map[string]string{"reason": "Certificate no longer needed"}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/"+rejectJobID+"/reject", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
// Verify job moved to Cancelled
job := jobRepo.jobs[rejectJobID]
if job.Status != domain.JobStatusCancelled {
t.Errorf("expected Cancelled after rejection, got %s", job.Status)
}
})
t.Run("ApproveNonexistentJob", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/job-ghost/approve", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
})
t.Run("ApproveNonAwaitingJob", func(t *testing.T) {
// The first job is already Pending (approved earlier) — approving again should fail
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/jobs/"+jobID+"/approve", bytes.NewReader([]byte("{}")))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
t.Error("expected error when approving non-AwaitingApproval job")
}
})
}
// TestNotificationEndpoints exercises the M3 notification API.
func TestNotificationEndpoints(t *testing.T) {
server, _, _, _ := setupTestServer(t)
t.Run("ListNotifications_Empty", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/notifications?page=1&per_page=10")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
total, ok := respBody["total"].(float64)
if !ok {
t.Log("Warning: total field not found or not a number")
} else if total != 0 {
t.Logf("Found %d notifications (expected 0 on fresh setup)", int(total))
}
})
}
// TestCRLEndpoint exercises the RFC 5280 DER-encoded CRL endpoint served
// unauthenticated at /.well-known/pki/crl/{issuer_id} (M-006 relocation from
// the pre-M-006 JSON CRL at /api/v1/crl, which was removed entirely because
// RFC 5280 §5 defines only the DER wire format).
func TestCRLEndpoint(t *testing.T) {
server, _, _, _ := setupTestServer(t)
t.Run("GetDERCRL_Unauthenticated", func(t *testing.T) {
// Intentionally no Authorization header — relying parties can't present
// a certctl API key, so the PKI endpoints are exposed under the
// RFC 8615 `.well-known` namespace with auth bypassed.
resp, err := http.Get(server.URL + "/.well-known/pki/crl/iss-local")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
if ct := resp.Header.Get("Content-Type"); ct != "application/pkix-crl" {
t.Errorf("expected Content-Type application/pkix-crl, got %s", ct)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body failed: %v", err)
}
if len(body) == 0 {
t.Error("expected non-empty DER CRL body")
}
t.Logf("DER CRL response: %d bytes", len(body))
})
}
// TestPaginationAcrossEndpoints verifies pagination parameters work consistently.
func TestPaginationAcrossEndpoints(t *testing.T) {
server, _, _, _ := setupTestServer(t)
endpoints := []struct {
name string
url string
}{
{"Certificates", "/api/v1/certificates?page=1&per_page=5"},
{"Agents", "/api/v1/agents?page=1&per_page=5"},
{"Jobs", "/api/v1/jobs?page=1&per_page=5"},
{"Audit", "/api/v1/audit?page=1&per_page=5"},
{"Notifications", "/api/v1/notifications?page=1&per_page=5"},
{"Policies", "/api/v1/policies?page=1&per_page=5"},
}
for _, ep := range endpoints {
t.Run(ep.name, func(t *testing.T) {
resp, err := http.Get(server.URL + ep.url)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Errorf("expected 200 for %s, got %d: %s", ep.name, resp.StatusCode, string(bodyBytes))
}
})
}
}
// TestIssuerAndTargetCRUD exercises issuer and target CRUD lifecycle.
func TestIssuerAndTargetCRUD(t *testing.T) {
server, _, _, _ := setupTestServer(t)
// Issuer CRUD
var issuerID string
t.Run("CreateIssuer", func(t *testing.T) {
payload := map[string]interface{}{
"id": "iss-test-ca",
"name": "Test Local CA",
"type": "GenericCA",
"config": json.RawMessage(`{"ca_common_name": "Test CA"}`),
}
body, _ := json.Marshal(payload)
resp, err := http.Post(server.URL+"/api/v1/issuers", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var issuer domain.Issuer
json.NewDecoder(resp.Body).Decode(&issuer)
issuerID = issuer.ID
t.Logf("Created issuer: %s", issuerID)
})
t.Run("GetIssuer", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/issuers/" + issuerID)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("ListIssuers", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/issuers")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
// Target CRUD
var targetID string
t.Run("CreateTarget", func(t *testing.T) {
payload := map[string]interface{}{
"id": "t-test-nginx",
"name": "Test NGINX",
"type": "NGINX",
"agent_id": "agent-1",
"config": json.RawMessage(`{"cert_path": "/etc/nginx/ssl/cert.pem"}`),
}
body, _ := json.Marshal(payload)
resp, err := http.Post(server.URL+"/api/v1/targets", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var target domain.DeploymentTarget
json.NewDecoder(resp.Body).Decode(&target)
targetID = target.ID
t.Logf("Created target: %s", targetID)
})
t.Run("GetTarget", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/targets/" + targetID)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("DeleteTarget", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/targets/"+targetID, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Accept either 200 or 204
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
t.Errorf("expected 200 or 204, got %d", resp.StatusCode)
}
})
t.Run("DeleteIssuer", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/issuers/"+issuerID, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
t.Errorf("expected 200 or 204, got %d", resp.StatusCode)
}
})
}
// TestM20EnhancedQueryAPI exercises M20 query API enhancements: sorting, time-range filters,
// cursor pagination, sparse fields, profile/agent filters, and the deployments endpoint.
func TestM20EnhancedQueryAPI(t *testing.T) {
server, certRepo, _, _ := setupTestServer(t)
// Setup: Create a certificate for testing
now := time.Now()
cert := &domain.ManagedCertificate{
ID: "mc-m20-test-1",
Name: "M20 Test Cert",
CommonName: "m20.example.com",
Environment: "production",
Status: domain.CertificateStatusActive,
IssuerID: "iss-local",
OwnerID: "owner-ops",
TeamID: "team-platform",
CertificateProfileID: "prof-standard",
CreatedAt: now,
UpdatedAt: now,
}
certRepo.certs["mc-m20-test-1"] = cert
t.Run("ListWithSortDescending", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates?sort=-notAfter&page=1&per_page=10")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
if _, ok := respBody["data"]; !ok {
t.Error("expected data field in response")
}
})
t.Run("ListWithSortAscending", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates?sort=createdAt&page=1&per_page=10")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
if _, ok := respBody["page"]; !ok {
t.Error("expected page-based pagination response")
}
})
t.Run("TimeRangeFilter_ExpiresBefore", func(t *testing.T) {
future := now.AddDate(0, 0, 365).Format(time.RFC3339)
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_before=" + future)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("TimeRangeFilter_ExpiresAfter", func(t *testing.T) {
past := now.AddDate(0, 0, -90).Format(time.RFC3339)
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_after=" + past)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("TimeRangeFilter_CreatedAfter", func(t *testing.T) {
past := now.AddDate(-1, 0, 0).Format(time.RFC3339)
resp, err := http.Get(server.URL + "/api/v1/certificates?created_after=" + past)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("SparseFields", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates?fields=id,common_name,status")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
if data, ok := respBody["data"].([]interface{}); ok && len(data) > 0 {
firstCert, ok := data[0].(map[string]interface{})
if !ok {
t.Fatal("expected cert object in data array")
}
// Should have requested fields
if _, ok := firstCert["id"]; !ok {
t.Error("expected 'id' field in sparse response")
}
// Should NOT have unrequested fields like 'environment'
if _, ok := firstCert["environment"]; ok {
t.Error("did not expect 'environment' field in sparse response")
}
}
})
t.Run("ProfileFilter", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates?profile_id=prof-standard")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("AgentIDFilter", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates?agent_id=agent-prod-001")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("CursorPagination", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates?cursor=abc123&page_size=10")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
if _, ok := respBody["next_cursor"]; !ok {
t.Error("expected next_cursor field with cursor pagination")
}
})
t.Run("CombinedFilters", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates?status=Active&environment=production&profile_id=prof-standard&sort=-createdAt&per_page=10")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
})
t.Run("GetCertificateDeployments_Success", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-m20-test-1/deployments")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Errorf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
var respBody map[string]interface{}
json.NewDecoder(resp.Body).Decode(&respBody)
if _, ok := respBody["data"]; !ok {
t.Error("expected data field in response")
}
if _, ok := respBody["total"]; !ok {
t.Error("expected total field in response")
}
})
t.Run("GetCertificateDeployments_NotFound", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-nonexistent-m20/deployments")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("expected 404, got %d", resp.StatusCode)
}
})
t.Run("InvalidTimeRange", func(t *testing.T) {
// Invalid RFC3339 should be silently ignored (no filter applied)
resp, err := http.Get(server.URL + "/api/v1/certificates?expires_before=not-a-date&page=1&per_page=10")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("expected 200 (invalid time ignored), got %d", resp.StatusCode)
}
})
}
// generateE2ECSRPEM creates a valid ECDSA P-256 CSR PEM for integration testing.
func generateE2ECSRPEM(t *testing.T, cn string, sans []string) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn},
DNSNames: sans,
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
if err != nil {
t.Fatalf("create CSR: %v", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}))
}
// generateE2ECSRBase64DER creates a valid base64-encoded DER CSR for EST wire format testing.
func generateE2ECSRBase64DER(t *testing.T, cn string, sans []string) string {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cn},
DNSNames: sans,
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
if err != nil {
t.Fatalf("create CSR: %v", err)
}
return base64.StdEncoding.EncodeToString(csrDER)
}
// TestPrometheusMetrics exercises the Prometheus metrics endpoint (M22).
func TestPrometheusMetrics(t *testing.T) {
server, _, _, _ := setupTestServer(t)
t.Run("GetPrometheusMetrics_Success", func(t *testing.T) {
resp, err := http.Get(server.URL + "/api/v1/metrics/prometheus")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
// Verify Content-Type contains text/plain
contentType := resp.Header.Get("Content-Type")
if !strings.Contains(contentType, "text/plain") {
t.Errorf("expected Content-Type containing 'text/plain', got %s", contentType)
}
// Read and verify Prometheus format
body, _ := io.ReadAll(resp.Body)
bodyStr := string(body)
// Should contain HELP and TYPE lines for metrics
if !strings.Contains(bodyStr, "# HELP") {
t.Error("expected HELP line in Prometheus response")
}
if !strings.Contains(bodyStr, "# TYPE") {
t.Error("expected TYPE line in Prometheus response")
}
// Should contain metric lines (gauge, counter, uptime)
if !strings.Contains(bodyStr, "certctl_") {
t.Error("expected certctl_ prefixed metrics in response")
}
t.Logf("Prometheus metrics endpoint working, body size: %d bytes", len(bodyStr))
})
t.Run("GetPrometheusMetrics_MethodNotAllowed", func(t *testing.T) {
resp, err := http.Post(server.URL+"/api/v1/metrics/prometheus", "application/json", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", resp.StatusCode)
}
})
}
// TestESTEndpoints exercises the EST (RFC 7030) enrollment endpoints end-to-end (M23).
func TestESTEndpoints(t *testing.T) {
server, _, _, _ := setupTestServer(t)
// ===========================
// GET /cacerts — CA certificate chain
// ===========================
t.Run("GetCACerts_Success", func(t *testing.T) {
resp, err := http.Get(server.URL + "/.well-known/est/cacerts")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "application/pkcs7-mime") {
t.Errorf("expected application/pkcs7-mime content type, got %s", ct)
}
cte := resp.Header.Get("Content-Transfer-Encoding")
if cte != "base64" {
t.Errorf("expected base64 content-transfer-encoding, got %s", cte)
}
bodyBytes, _ := io.ReadAll(resp.Body)
if len(bodyBytes) == 0 {
t.Error("expected non-empty PKCS#7 response body")
}
})
t.Run("GetCACerts_MethodNotAllowed", func(t *testing.T) {
resp, err := http.Post(server.URL+"/.well-known/est/cacerts", "application/json", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", resp.StatusCode)
}
})
// ===========================
// POST /simpleenroll — certificate enrollment
// ===========================
t.Run("SimpleEnroll_PEM_Success", func(t *testing.T) {
csrPEM := generateE2ECSRPEM(t, "est-test.example.com", []string{"est-test.example.com"})
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "application/pkcs7-mime") {
t.Errorf("expected application/pkcs7-mime, got %s", ct)
}
})
t.Run("SimpleEnroll_Base64DER_Success", func(t *testing.T) {
csrB64 := generateE2ECSRBase64DER(t, "est-der.example.com", []string{"est-der.example.com"})
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrB64))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("SimpleEnroll_EmptyBody", func(t *testing.T) {
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(""))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for empty body, got %d", resp.StatusCode)
}
})
t.Run("SimpleEnroll_InvalidCSR", func(t *testing.T) {
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader("not-a-valid-csr"))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("expected 400 for invalid CSR, got %d", resp.StatusCode)
}
})
t.Run("SimpleEnroll_MissingCN", func(t *testing.T) {
csrPEM := generateE2ECSRPEM(t, "", []string{"no-cn.example.com"})
resp, err := http.Post(server.URL+"/.well-known/est/simpleenroll", "application/pkcs10", strings.NewReader(csrPEM))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Should fail because EST requires a Common Name
if resp.StatusCode == http.StatusOK {
t.Error("expected error for CSR without Common Name")
}
})
t.Run("SimpleEnroll_MethodNotAllowed", func(t *testing.T) {
resp, err := http.Get(server.URL + "/.well-known/est/simpleenroll")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", resp.StatusCode)
}
})
// ===========================
// POST /simplereenroll — certificate re-enrollment
// ===========================
t.Run("SimpleReEnroll_Success", func(t *testing.T) {
csrPEM := generateE2ECSRPEM(t, "renew-est.example.com", []string{"renew-est.example.com"})
resp, err := http.Post(server.URL+"/.well-known/est/simplereenroll", "application/pkcs10", strings.NewReader(csrPEM))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
}
})
t.Run("SimpleReEnroll_MethodNotAllowed", func(t *testing.T) {
resp, err := http.Get(server.URL + "/.well-known/est/simplereenroll")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", resp.StatusCode)
}
})
// ===========================
// GET /csrattrs — CSR attributes
// ===========================
t.Run("GetCSRAttrs_NoContent", func(t *testing.T) {
resp, err := http.Get(server.URL + "/.well-known/est/csrattrs")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
// Default implementation returns nil attrs → 204 No Content
if resp.StatusCode != http.StatusNoContent {
t.Errorf("expected 204, got %d", resp.StatusCode)
}
})
t.Run("GetCSRAttrs_MethodNotAllowed", func(t *testing.T) {
resp, err := http.Post(server.URL+"/.well-known/est/csrattrs", "application/json", nil)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", resp.StatusCode)
}
})
}