mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:01:36 +00:00
eef1db0f0a
Coverage Gap Audit findings D-005 (P0) + D-006 (P1) fixed together in a
single commit because they share the same root cause — policy CRUD sending
values the backend silently rejects — and splitting them would leave a
half-working UI between commits.
## D-005 (P0): PoliciesPage dropdown 400s every Create Policy
Root cause
----------
`web/src/pages/PoliciesPage.tsx` populated the Type `<select>` from a
hardcoded `['key_algorithm', 'ownership', 'allowed_issuers', ...]` array.
The backend's `internal/api/handler/validators.go::ValidatePolicyType`
enforces the TitleCase allowlist `AllowedIssuers`, `AllowedDomains`,
`RequiredMetadata`, `AllowedEnvironments`, `RenewalLeadTime` — defined in
`internal/domain/policy.go`. Every Create Policy request was rejected with
`400 invalid policy type`. The error surfaced only as a transient toast;
the modal closed anyway. Silent user-visible failure.
Fix
---
- `web/src/api/types.ts`: added `POLICY_TYPES` and `POLICY_SEVERITIES`
tuples with `as const` and narrowed `PolicyRule.type`, `.severity`, and
`PolicyViolation.severity` to the literal-union types. Dropdown is now
sourced from the tuple; casing drift becomes a compile error.
- `web/src/pages/PoliciesPage.tsx`: rekeyed `severityStyles` /
`severityDots` to the TitleCase values, added `humanize()` for display
(AllowedIssuers → "Allowed Issuers"), removed the `badge-neutral`
fallback that was papering over the mismatch.
- `web/src/api/types.test.ts` (new): pins both tuples exactly. If anyone
edits one side of the frontend/backend contract without the other, CI
fails with a clear assertion. Pure-TS vitest, no RTL dependency.
## D-006 (P1): `severity` field silently dropped on create/update
Root cause
----------
`PolicyRule` had no `Severity` field in `internal/domain/policy.go`. The
frontend has always sent `severity` on create/update, but Go's
`json.Decoder` (default settings, no `DisallowUnknownFields`) silently
dropped it. The value never reached PostgreSQL. Every rule rendered with
the same severity because there was no severity — just a display
computation downstream.
Fix: option (b), full-stack schema add (not delete-the-field)
-------------------------------------------------------------
- Migration `000013_policy_rule_severity` (up + down): adds
`severity VARCHAR(50) NOT NULL DEFAULT 'Warning'` to `policy_rules` with
CHECK constraint `severity IN ('Warning', 'Error', 'Critical')`. No
index — three-value column on a low-thousands-rows table, planner will
seq-scan regardless. PG 11+ metadata-only ADD COLUMN, safe on live data.
- `internal/domain/policy.go`: added `Severity PolicySeverity` field.
- `internal/repository/postgres/policy.go`: plumbed `severity` through
ListRules SELECT + Scan, GetRule SELECT + Scan, CreateRule INSERT,
UpdateRule UPDATE (4 queries).
- `internal/service/policy.go::UpdatePolicy`: if the client omits
severity on a PUT (zero-value empty string), fetch the existing rule
and preserve its severity. Without this, partial updates would trip the
NOT NULL CHECK and 500. Preserves pre-existing behavior for Name/Type
(out of scope).
- `internal/api/handler/policies.go::CreatePolicy`: default empty severity
to `'Warning'`, then validate via `ValidatePolicySeverity`. 400 with
clear message instead of 500 on CHECK violation. `UpdatePolicy`:
validates severity only when provided.
- `internal/mcp/types.go` + `internal/mcp/tools.go`: added optional
`severity` on the MCP `create_policy` / `update_policy` tool inputs so
LLM callers stay in sync with the wire contract.
- `api/openapi.yaml`: added `severity` to the `PolicyRule` schema with
the enum and default.
Acceptance criterion (user-defined)
-----------------------------------
"Create a rule with severity=Critical, reload the page, and still see
Critical — no silent drops." Verified end-to-end: frontend sends
`severity: "Critical"`, handler validates, service persists, DB stores,
GET returns, React renders the correct badge.
Seed data
---------
`migrations/seed.sql`: four demo rules now have differentiated severities
— `pr-require-owner` → Warning, `pr-allowed-environments` → Error,
`pr-max-certificate-lifetime` → Critical, `pr-min-renewal-window` →
Warning. The user called out that seeding all four at the same severity
makes the feature look decorative; differentiation demonstrates the
column carries real signal.
## Integration test fix (side effect of D-006)
`internal/integration/e2e_test.go::TestCrossResourceWorkflow/CreatePolicy`
was sending `"severity": "High"` — a value from the pre-audit severity
vocabulary that the new `ValidatePolicySeverity` correctly rejects with
400. Changed to `"Error"` (closest semantic match in the new TitleCase
allowlist). Only severity reference in the integration/ directory;
verified via grep.
## Out of scope, logged for follow-up (d/D-008)
Three policy-engine drift issues orthogonal to D-005 + D-006, explicitly
deferred per direction:
1. `migrations/seed.sql` policy_rules INSERTs use lowercase TYPE values
(`'ownership'`, `'environment'`, `'lifetime'`, `'renewal_window'`).
These are load-bearing on `internal/service/policy.go::evaluateRule`'s
`switch rule.Type` (which also uses the lowercase strings). Migrating
requires coordinated changes across seed + evaluation engine.
2. `migrations/seed_demo.sql:482-483` contains lowercase `'critical'`
severity — will now fail the new CHECK constraint. Separate fix.
3. `evaluateRule` hardcodes `Severity: domain.PolicySeverityWarning` on
emitted violations and ignores the configured `rule.Config`. The new
severity column is read correctly on the CRUD path but not yet
consulted during evaluation.
## Verification
Backend:
- `go build ./...` — clean
- `go vet ./...` — clean
- `go test -short ./...` — all packages green, including
`internal/service` (policy service), `internal/api/handler` (policy +
MCP handler tests), `internal/integration` (e2e_test.go after fix),
`internal/domain`, `internal/repository/postgres`.
Frontend:
- `tsc --noEmit` — clean
- `vitest run` — 223/223 passing (4 new assertions in types.test.ts)
- `vite build` — clean (only the pre-existing chunk-size warning)
1169 lines
38 KiB
Go
1169 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 CRL listing endpoint (M15a).
|
|
func TestCRLEndpoint(t *testing.T) {
|
|
server, _, _, _ := setupTestServer(t)
|
|
|
|
t.Run("GetCRL_JSON", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/crl")
|
|
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 crl map[string]interface{}
|
|
json.NewDecoder(resp.Body).Decode(&crl)
|
|
if crl["version"] == nil {
|
|
t.Error("expected version field in CRL response")
|
|
}
|
|
if crl["entries"] == nil {
|
|
t.Error("expected entries field in CRL response")
|
|
}
|
|
t.Logf("CRL response: version=%v, entries_count=%v", crl["version"], crl["total"])
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|