mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:32:02 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
1177 lines
38 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|