mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01: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.
847 lines
28 KiB
Go
847 lines
28 KiB
Go
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/certctl-io/certctl/internal/api/handler"
|
|
"github.com/certctl-io/certctl/internal/api/router"
|
|
"github.com/certctl-io/certctl/internal/connector/issuer/local"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/service"
|
|
)
|
|
|
|
// setupTestServer creates a fully-wired test server for negative path testing.
|
|
func setupTestServer(t *testing.T) (*httptest.Server, *mockCertificateRepository, *mockJobRepository, *mockAgentRepository) {
|
|
t.Helper()
|
|
|
|
certRepo := newMockCertificateRepository()
|
|
jobRepo := newMockJobRepository()
|
|
auditRepo := newMockAuditRepository()
|
|
agentRepo := newMockAgentRepository()
|
|
targetRepo := newMockTargetRepository()
|
|
notifRepo := newMockNotificationRepository()
|
|
policyRepo := newMockPolicyRepository()
|
|
renewalPolicyRepo := newMockRenewalPolicyRepository()
|
|
issuerRepo := newMockIssuerRepository()
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
localCA := local.New(nil, logger)
|
|
|
|
issuerRegistry := service.NewIssuerRegistry(logger)
|
|
issuerRegistry.Set("iss-local", service.NewIssuerConnectorAdapter(localCA))
|
|
|
|
revocationRepo := newMockRevocationRepository()
|
|
|
|
auditService := service.NewAuditService(auditRepo)
|
|
policyService := service.NewPolicyService(policyRepo, auditService)
|
|
certificateService := service.NewCertificateService(certRepo, policyService, auditService)
|
|
notificationService := service.NewNotificationService(notifRepo, make(map[string]service.Notifier))
|
|
|
|
// Wire decomposed sub-services (TICKET-007)
|
|
revocationSvc := service.NewRevocationSvc(certRepo, revocationRepo, auditService)
|
|
revocationSvc.SetNotificationService(notificationService)
|
|
revocationSvc.SetIssuerRegistry(issuerRegistry)
|
|
caOperationsSvc := service.NewCAOperationsSvc(revocationRepo, certRepo, nil)
|
|
caOperationsSvc.SetIssuerRegistry(issuerRegistry)
|
|
certificateService.SetRevocationSvc(revocationSvc)
|
|
certificateService.SetCAOperationsSvc(caOperationsSvc)
|
|
renewalService := service.NewRenewalService(certRepo, jobRepo, renewalPolicyRepo, nil, auditService, notificationService, issuerRegistry, "server")
|
|
deploymentService := service.NewDeploymentService(jobRepo, targetRepo, agentRepo, certRepo, auditService, notificationService)
|
|
ownerRepo := newMockOwnerRepository()
|
|
jobService := service.NewJobService(jobRepo, certRepo, ownerRepo, renewalService, deploymentService, logger)
|
|
agentService := service.NewAgentService(agentRepo, certRepo, jobRepo, targetRepo, auditService, issuerRegistry, renewalService)
|
|
// 32-byte AES-256 test key — C-2 remediation makes IssuerService fail closed
|
|
// without a configured CERTCTL_CONFIG_ENCRYPTION_KEY. Happy-path CRUD tests
|
|
// must supply a real key so the encrypt path runs instead of returning
|
|
// ErrEncryptionKeyRequired.
|
|
testEncryptionKey := "0123456789abcdef0123456789abcdef"
|
|
issuerService := service.NewIssuerService(issuerRepo, auditService, issuerRegistry, testEncryptionKey, logger)
|
|
|
|
certificateHandler := handler.NewCertificateHandler(certificateService)
|
|
issuerHandler := handler.NewIssuerHandler(issuerService)
|
|
targetHandler := handler.NewTargetHandler(&mockTargetService{targetRepo: targetRepo, auditService: auditService})
|
|
agentHandler := handler.NewAgentHandler(agentService, "") // Bundle-5 / H-007: integration fixture uses warn-mode pass-through
|
|
jobHandler := handler.NewJobHandler(jobService)
|
|
policyHandler := handler.NewPolicyHandler(policyService)
|
|
profileHandler := handler.NewProfileHandler(&mockProfileService{})
|
|
teamHandler := handler.NewTeamHandler(&mockTeamService{})
|
|
ownerHandler := handler.NewOwnerHandler(&mockOwnerService{})
|
|
agentGroupHandler := handler.NewAgentGroupHandler(&mockAgentGroupService{})
|
|
auditHandler := handler.NewAuditHandler(auditService)
|
|
notificationHandler := handler.NewNotificationHandler(notificationService)
|
|
statsHandler := handler.NewStatsHandler(&mockStatsService{})
|
|
metricsHandler := handler.NewMetricsHandler(&mockStatsService{}, time.Now())
|
|
healthHandler := handler.NewHealthHandler("none", nil) // Bundle-5 / H-006: integration fixture has no DB pool wired
|
|
discoveryHandler := handler.NewDiscoveryHandler(&mockDiscoveryService{})
|
|
networkScanHandler := handler.NewNetworkScanHandler(&mockNetworkScanService{})
|
|
verificationHandler := handler.NewVerificationHandler(&mockVerificationService{})
|
|
|
|
// EST handler — uses real Local CA issuer via ESTService
|
|
localCAConnector, _ := issuerRegistry.Get("iss-local")
|
|
estService := service.NewESTService("iss-local", localCAConnector, auditService, logger)
|
|
estHandler := handler.NewESTHandler(estService)
|
|
|
|
r := router.New()
|
|
r.RegisterHandlers(router.HandlerRegistry{
|
|
Certificates: certificateHandler,
|
|
Issuers: issuerHandler,
|
|
Targets: targetHandler,
|
|
Agents: agentHandler,
|
|
Jobs: jobHandler,
|
|
Policies: policyHandler,
|
|
Profiles: profileHandler,
|
|
Teams: teamHandler,
|
|
Owners: ownerHandler,
|
|
AgentGroups: agentGroupHandler,
|
|
Audit: auditHandler,
|
|
Notifications: notificationHandler,
|
|
Stats: statsHandler,
|
|
Metrics: metricsHandler,
|
|
Health: healthHandler,
|
|
Discovery: discoveryHandler,
|
|
NetworkScan: networkScanHandler,
|
|
Verification: verificationHandler,
|
|
BulkRevocation: handler.BulkRevocationHandler{},
|
|
})
|
|
// EST RFC 7030 hardening Phase 1: RegisterESTHandlers takes a map
|
|
// keyed by PathID. Empty PathID = legacy /.well-known/est/ root.
|
|
r.RegisterESTHandlers(map[string]handler.ESTHandler{"": estHandler})
|
|
// M-006: CRL + OCSP live under /.well-known/pki/ (RFC 5280 + RFC 6960 + RFC 8615).
|
|
// The negative_test integration suite exercises the DER CRL at this path with
|
|
// no Authorization header to verify the relying-party contract.
|
|
r.RegisterPKIHandlers(certificateHandler)
|
|
|
|
// Bundle-4 / M-021: the EST handler now requires `r.TLS != nil` per
|
|
// verifyESTTransport. The integration tests use httptest.NewServer (HTTP,
|
|
// not HTTPS) for simplicity. Wrap the router with a fake-TLS injector that
|
|
// sets a synthetic `*tls.ConnectionState` on every request — mimicking what
|
|
// the real TLS listener does in production. The injector is test-only;
|
|
// production paths use the real listener's `r.TLS`.
|
|
wrapped := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
if req.TLS == nil {
|
|
req.TLS = &tls.ConnectionState{
|
|
HandshakeComplete: true,
|
|
Version: tls.VersionTLS13,
|
|
}
|
|
}
|
|
r.ServeHTTP(w, req)
|
|
})
|
|
server := httptest.NewServer(wrapped)
|
|
t.Cleanup(func() { server.Close() })
|
|
|
|
return server, certRepo, jobRepo, agentRepo
|
|
}
|
|
|
|
// TestNegativePaths exercises error paths and edge cases.
|
|
func TestNegativePaths(t *testing.T) {
|
|
server, _, _, _ := setupTestServer(t)
|
|
|
|
// ======================
|
|
// Nonexistent resource lookups
|
|
// ======================
|
|
t.Run("GetNonexistentCertificate", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-does-not-exist")
|
|
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("GetNonexistentAgent", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/agents/agent-ghost")
|
|
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("GetNonexistentJob", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/jobs/job-ghost")
|
|
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)
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Invalid request bodies
|
|
// ======================
|
|
t.Run("CreateCertificateInvalidJSON", func(t *testing.T) {
|
|
resp, err := http.Post(server.URL+"/api/v1/certificates", "application/json", bytes.NewReader([]byte("not json")))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected 400, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
})
|
|
|
|
t.Run("CreateCertificateMissingCommonName", func(t *testing.T) {
|
|
body := map[string]interface{}{
|
|
"name": "Test Cert",
|
|
"environment": "test",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
resp, err := http.Post(server.URL+"/api/v1/certificates", "application/json", bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected 400, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
})
|
|
|
|
t.Run("CreatePolicyInvalidType", func(t *testing.T) {
|
|
body := map[string]interface{}{
|
|
"name": "Bad Policy",
|
|
"type": "NonexistentType",
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
resp, err := http.Post(server.URL+"/api/v1/policies", "application/json", bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
t.Errorf("expected 400, got %d. Body: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Invalid CSR submission
|
|
// ======================
|
|
t.Run("SubmitInvalidCSR", func(t *testing.T) {
|
|
// First register an agent
|
|
agentBody := map[string]interface{}{
|
|
"name": "test-agent",
|
|
"hostname": "test-host",
|
|
}
|
|
agentBytes, _ := json.Marshal(agentBody)
|
|
|
|
regResp, err := http.Post(server.URL+"/api/v1/agents", "application/json", bytes.NewReader(agentBytes))
|
|
if err != nil {
|
|
t.Fatalf("register agent failed: %v", err)
|
|
}
|
|
defer regResp.Body.Close()
|
|
|
|
if regResp.StatusCode != http.StatusCreated {
|
|
bodyBytes, _ := io.ReadAll(regResp.Body)
|
|
t.Fatalf("expected 201, got %d. Body: %s", regResp.StatusCode, string(bodyBytes))
|
|
}
|
|
|
|
var agentResp struct {
|
|
Agent domain.Agent `json:"agent"`
|
|
APIKey string `json:"api_key"`
|
|
}
|
|
if err := json.NewDecoder(regResp.Body).Decode(&agentResp); err != nil {
|
|
t.Fatalf("failed to decode agent response: %v", err)
|
|
}
|
|
|
|
// Submit garbage CSR
|
|
csrBody := map[string]interface{}{
|
|
"csr_pem": "not a valid CSR",
|
|
}
|
|
csrBytes, _ := json.Marshal(csrBody)
|
|
|
|
csrResp, err := http.Post(
|
|
fmt.Sprintf("%s/api/v1/agents/%s/csr", server.URL, agentResp.Agent.ID),
|
|
"application/json",
|
|
bytes.NewReader(csrBytes),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("CSR submission failed: %v", err)
|
|
}
|
|
defer csrResp.Body.Close()
|
|
|
|
// Should reject — either 400 (bad CSR format) or 500 (no cert to sign for)
|
|
if csrResp.StatusCode == http.StatusOK || csrResp.StatusCode == http.StatusCreated {
|
|
t.Errorf("expected error status for invalid CSR, got %d", csrResp.StatusCode)
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Heartbeat for nonexistent agent
|
|
// ======================
|
|
t.Run("HeartbeatNonexistentAgent", func(t *testing.T) {
|
|
heartbeatBody := map[string]interface{}{
|
|
"status": "healthy",
|
|
}
|
|
bodyBytes, _ := json.Marshal(heartbeatBody)
|
|
|
|
resp, err := http.Post(
|
|
server.URL+"/api/v1/agents/agent-nonexistent/heartbeat",
|
|
"application/json",
|
|
bytes.NewReader(bodyBytes),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Should fail — agent doesn't exist
|
|
if resp.StatusCode == http.StatusOK {
|
|
t.Errorf("expected error status for nonexistent agent heartbeat, got 200")
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Method not allowed
|
|
// ======================
|
|
t.Run("PutToListEndpoint", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodPut, server.URL+"/api/v1/certificates", 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 {
|
|
t.Errorf("expected error for PUT on list endpoint, got 200")
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Empty list responses
|
|
// ======================
|
|
t.Run("ListEmptyCertificates", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/certificates")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("expected 200 for empty list, got %d", resp.StatusCode)
|
|
}
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
t.Fatalf("failed to decode: %v", err)
|
|
}
|
|
|
|
total, ok := result["total"].(float64)
|
|
if !ok || total != 0 {
|
|
t.Errorf("expected total 0, got %v", result["total"])
|
|
}
|
|
})
|
|
|
|
t.Run("ListEmptyJobs", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/jobs")
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("expected 200 for empty list, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
// ======================
|
|
// Trigger renewal on nonexistent cert
|
|
// ======================
|
|
t.Run("TriggerRenewalNonexistentCert", func(t *testing.T) {
|
|
resp, err := http.Post(
|
|
server.URL+"/api/v1/certificates/mc-ghost/renew",
|
|
"application/json",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
|
t.Errorf("expected error for renewal of nonexistent cert, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestCertificateLifecycleWithExpiredCert verifies handling of an expired certificate.
|
|
func TestCertificateLifecycleWithExpiredCert(t *testing.T) {
|
|
server, certRepo, _, _ := setupTestServer(t)
|
|
|
|
// Create an already-expired certificate directly in the repo
|
|
expiredTime := time.Now().Add(-24 * time.Hour)
|
|
expiredCert := &domain.ManagedCertificate{
|
|
ID: "mc-expired-001",
|
|
Name: "Expired Cert",
|
|
CommonName: "expired.example.com",
|
|
Status: domain.CertificateStatusExpired,
|
|
Environment: "prod",
|
|
IssuerID: "iss-local",
|
|
RenewalPolicyID: "rp-default",
|
|
ExpiresAt: expiredTime,
|
|
CreatedAt: time.Now().Add(-90 * 24 * time.Hour),
|
|
UpdatedAt: time.Now(),
|
|
}
|
|
certRepo.certs[expiredCert.ID] = expiredCert
|
|
|
|
// Verify we can retrieve the expired cert
|
|
t.Run("GetExpiredCert", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/certificates/mc-expired-001")
|
|
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 cert domain.ManagedCertificate
|
|
if err := json.NewDecoder(resp.Body).Decode(&cert); err != nil {
|
|
t.Fatalf("failed to decode: %v", err)
|
|
}
|
|
|
|
if cert.Status != domain.CertificateStatusExpired {
|
|
t.Errorf("expected status Expired, got %s", cert.Status)
|
|
}
|
|
})
|
|
|
|
// Trigger renewal on expired cert — should succeed (creating a renewal job)
|
|
t.Run("TriggerRenewalOnExpiredCert", func(t *testing.T) {
|
|
resp, err := http.Post(
|
|
server.URL+"/api/v1/certificates/mc-expired-001/renew",
|
|
"application/json",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Renewal should be accepted (creates a job) or return an error
|
|
// if the service doesn't allow renewal on expired certs
|
|
t.Logf("Renewal trigger on expired cert returned status: %d", resp.StatusCode)
|
|
})
|
|
}
|
|
|
|
// TestM11bEndpoints exercises the M11b endpoints: teams, owners, agent groups.
|
|
// Tests M11b feature coverage through the HTTP API.
|
|
func TestM11bEndpoints(t *testing.T) {
|
|
server, _, _, _ := setupTestServer(t)
|
|
|
|
// ========================
|
|
// Teams API
|
|
// ========================
|
|
t.Run("Teams", func(t *testing.T) {
|
|
t.Run("CreateTeam_Success", func(t *testing.T) {
|
|
payload := map[string]string{"name": "Platform", "description": "Platform team"}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := http.Post(server.URL+"/api/v1/teams", "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.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
var team domain.Team
|
|
json.NewDecoder(resp.Body).Decode(&team)
|
|
if team.Name != "Platform" {
|
|
t.Errorf("expected name=Platform, got %s", team.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateTeam_MissingName", func(t *testing.T) {
|
|
payload := map[string]string{"description": "No name team"}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateTeam_NameTooLong", func(t *testing.T) {
|
|
longName := ""
|
|
for i := 0; i < 256; i++ {
|
|
longName += "a"
|
|
}
|
|
payload := map[string]string{"name": longName}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateTeam_InvalidJSON", func(t *testing.T) {
|
|
resp, err := http.Post(server.URL+"/api/v1/teams", "application/json", bytes.NewReader([]byte("not json")))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("GetTeam_NotFound", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/teams/t-nonexistent")
|
|
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("ListTeams_Empty", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/teams")
|
|
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("DeleteTeam_Success", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/teams/t-platform", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("expected 204, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("ListTeams_MethodNotAllowed", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/teams", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ========================
|
|
// Owners API
|
|
// ========================
|
|
t.Run("Owners", func(t *testing.T) {
|
|
t.Run("CreateOwner_Success", func(t *testing.T) {
|
|
payload := map[string]string{"name": "Alice", "email": "alice@example.com", "team_id": "t-platform"}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := http.Post(server.URL+"/api/v1/owners", "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.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
var owner domain.Owner
|
|
json.NewDecoder(resp.Body).Decode(&owner)
|
|
if owner.Name != "Alice" {
|
|
t.Errorf("expected name=Alice, got %s", owner.Name)
|
|
}
|
|
if owner.Email != "alice@example.com" {
|
|
t.Errorf("expected email=alice@example.com, got %s", owner.Email)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateOwner_MissingName", func(t *testing.T) {
|
|
payload := map[string]string{"email": "bob@example.com"}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := http.Post(server.URL+"/api/v1/owners", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("GetOwner_NotFound", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/owners/o-nonexistent")
|
|
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("ListOwners_Empty", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/owners")
|
|
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("DeleteOwner_Success", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/owners/o-alice", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("expected 204, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
})
|
|
|
|
// ========================
|
|
// Agent Groups API
|
|
// ========================
|
|
t.Run("AgentGroups", func(t *testing.T) {
|
|
t.Run("CreateAgentGroup_Success", func(t *testing.T) {
|
|
payload := map[string]interface{}{
|
|
"name": "Linux Servers",
|
|
"description": "All linux-based agents",
|
|
"match_os": "linux",
|
|
"match_architecture": "amd64",
|
|
"enabled": true,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := http.Post(server.URL+"/api/v1/agent-groups", "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.Errorf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
|
}
|
|
var group domain.AgentGroup
|
|
json.NewDecoder(resp.Body).Decode(&group)
|
|
if group.Name != "Linux Servers" {
|
|
t.Errorf("expected name=Linux Servers, got %s", group.Name)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateAgentGroup_MissingName", func(t *testing.T) {
|
|
payload := map[string]string{"description": "No name group"}
|
|
body, _ := json.Marshal(payload)
|
|
resp, err := http.Post(server.URL+"/api/v1/agent-groups", "application/json", bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("expected 400, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("GetAgentGroup_NotFound", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/agent-groups/ag-nonexistent")
|
|
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("ListAgentGroups_Empty", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/agent-groups")
|
|
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("DeleteAgentGroup_Success", func(t *testing.T) {
|
|
req, _ := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/agent-groups/ag-linux", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Errorf("expected 204, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("ListAgentGroupMembers_Empty", func(t *testing.T) {
|
|
resp, err := http.Get(server.URL + "/api/v1/agent-groups/ag-linux/members")
|
|
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)
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// TestRevocationEndpoints exercises the revocation API endpoints through a full integration stack.
|
|
func TestRevocationEndpoints(t *testing.T) {
|
|
server, certRepo, _, _ := setupTestServer(t)
|
|
|
|
// Create a test certificate with a version
|
|
now := time.Now()
|
|
cert := &domain.ManagedCertificate{
|
|
ID: "mc-revoke-test",
|
|
Name: "Revocation Test Cert",
|
|
CommonName: "revoke-test.example.com",
|
|
SANs: []string{},
|
|
Environment: "test",
|
|
OwnerID: "owner-test",
|
|
TeamID: "team-test",
|
|
IssuerID: "iss-local",
|
|
RenewalPolicyID: "policy-1",
|
|
Status: domain.CertificateStatusActive,
|
|
ExpiresAt: now.AddDate(0, 6, 0),
|
|
Tags: map[string]string{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
certRepo.certs["mc-revoke-test"] = cert
|
|
certRepo.versions["mc-revoke-test"] = []*domain.CertificateVersion{
|
|
{
|
|
ID: "cv-revoke-test",
|
|
CertificateID: "mc-revoke-test",
|
|
SerialNumber: "REVOKE-SERIAL-001",
|
|
NotBefore: now,
|
|
NotAfter: now.AddDate(1, 0, 0),
|
|
CreatedAt: now,
|
|
},
|
|
}
|
|
|
|
t.Run("RevokeCertificate_Success", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`{"reason":"keyCompromise"}`)
|
|
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-revoke-test/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))
|
|
}
|
|
|
|
var result map[string]string
|
|
json.NewDecoder(resp.Body).Decode(&result)
|
|
if result["status"] != "revoked" {
|
|
t.Errorf("expected status 'revoked', got %s", result["status"])
|
|
}
|
|
|
|
// Verify certificate status updated
|
|
if cert.Status != domain.CertificateStatusRevoked {
|
|
t.Errorf("expected Revoked status, got %s", cert.Status)
|
|
}
|
|
})
|
|
|
|
t.Run("RevokeCertificate_AlreadyRevoked", func(t *testing.T) {
|
|
body := bytes.NewBufferString(`{"reason":"superseded"}`)
|
|
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-revoke-test/revoke", "application/json", body)
|
|
if err != nil {
|
|
t.Fatalf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("expected 400 for already revoked, got %d", resp.StatusCode)
|
|
}
|
|
})
|
|
|
|
t.Run("RevokeCertificate_NotFound", func(t *testing.T) {
|
|
resp, err := http.Post(server.URL+"/api/v1/certificates/mc-nonexistent/revoke", "application/json", strings.NewReader("{}"))
|
|
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)
|
|
}
|
|
})
|
|
|
|
// M-006: the non-standard JSON CRL at GET /api/v1/crl was removed entirely.
|
|
// RFC 5280 §5 defines only the DER wire format, which is now served
|
|
// unauthenticated under /.well-known/pki/crl/{issuer_id} (RFC 8615) so
|
|
// relying parties can fetch revocation data without a certctl API key.
|
|
// We verify the contract by requesting with no Authorization header and
|
|
// asserting DER content-type + a non-empty body.
|
|
t.Run("GetDERCRL_Unauthenticated", func(t *testing.T) {
|
|
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))
|
|
}
|
|
|
|
ct := resp.Header.Get("Content-Type")
|
|
if 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")
|
|
}
|
|
})
|
|
}
|