mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:41:30 +00:00
8b75e0311b
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
purely the Go-tooling layer.
Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
namespace, not a Go import or GitHub repo URL. Stays.
This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
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/certctl-io/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)
|
|
}
|
|
})
|
|
}
|