mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:31:30 +00:00
14fcc82cda
Closes Rank 5 (Azure half) of the 2026-05-03 Infisical deep-research deliverable (cowork/infisical-deep-research-results.md Part 5). Pre-fix, certctl had no path to deploy certs to Azure-managed TLS- termination endpoints (Application Gateway / Front Door / App Service / Container Apps) — operators terminating TLS at Azure had to use manual `az keyvault certificate import` invocations or external automation. This commit lands the SDK-driven Azure Key Vault target connector that closes the gap, mirroring the AWS ACM target shape shipped in commit54033aa. Architecture: - internal/connector/target/azurekv/azurekv.go — Connector wraps *azcertificates.Client behind the KeyVaultClient interface seam (mirrors awsacm's ACMClient + awsacmpca's ACMPCAClient). Lives in azurekv.go alongside the PFX (PKCS#12) wrapping helper that bundles the operator-supplied PEM cert + chain + key into the base64-PFX wire format azcertificates.ImportCertificate accepts. - internal/connector/target/azurekv/sdk_client.go — SDK-loading code isolated so the test path (NewWithClient) compiles without pulling azcore + azidentity transitive deps into the test binary. DefaultAzureCredential / ManagedIdentityCredential / EnvironmentCredential / WorkloadIdentityCredential selected via Config.CredentialMode (closed enum). - Pre-deploy snapshot via GetCertificate(name, "" /* latest */) so on-import-failure rollback restores the previous cert. Mirrors Bundle 5+. The Azure-specific quirk: rollback creates a NEW VERSION (Key Vault doesn't support version-restore without soft-delete recovery, which we keep off the minimum-RBAC surface). Operators reading audit dashboards see e.g. v1=initial, v2=failed-renewal, v3=rollback-of-v2; the certctl-managed-by + certctl-certificate-id provenance tags + future certctl-rollback-of metadata tag let an operator filter rollback artifacts. - Provenance tags identical to AWS ACM (certctl-managed-by=certctl + certctl-certificate-id=<mc-id>), automatically applied on every import. Key Vault carries tags forward across versions (unlike ACM which strips on re-import), so no separate AddTags call is required. - DeploymentRequest.KeyPEM held in agent memory only; PFX wrapping happens in-memory via software.sslmate.com/src/go-pkcs12. No disk write. Tests: - azurekv_test.go: 13-subtest happy-path + validation matrix — ValidateConfig (success / missing-vault-url / malformed-vault- url / missing-cert-name / invalid-credential-mode / reserved- tag rejection), DeployCertificate (fresh import / rollback-on- serial-mismatch / empty-key-rejected / no-client-rejected / SDK-error-surfaced), ValidateOnly (returns sentinel), ValidateDeployment (serial match / mismatch). - All tests use the NewWithClient injection seam; no real-Azure API calls. - go test -short -count=1 ./internal/connector/target/azurekv/... green. Wiring: - internal/domain/connector.go: TargetTypeAzureKeyVault = "AzureKeyVault". - internal/service/target.go: validTargetTypes set extended. - cmd/agent/main.go::createTargetConnector: AzureKeyVault case arm mirroring the AWSACM shape exactly. - cmd/agent/agent_test.go::TestCreateTargetConnector_AllSupported Types: AzureKeyVault added to the type matrix + the InvalidJSON matrix (16 supported target types now, up from 15). go.mod / go.sum: - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 (direct). - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 (direct). - github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/ azcertificates v1.4.0 (direct). The deprecated /keyvault/azcertificates path appears as a transitive indirect via Microsoft's microsoft-authentication-library-for-go; we use the new /security/keyvault/ path exclusively. Documentation: - docs/connectors.md "Azure Key Vault" section: config table, RBAC role recipe (off-the-shelf "Key Vault Certificates Officer" or custom role with 3 data-plane actions), AKS workload-identity / managed-identity / service-principal / default credential recipes, atomic-rollback contract + Azure-version semantics explanation, soft-delete caveat, App Gateway / Front Door Terraform attachment snippet, threat model carve-outs (no disk writes, mandatory provenance tags, no long-lived secrets in Config), 5-bullet procurement checklist crib. Out of scope (intentional, flagged in V3-Pro forward path): - Azure Front Door direct-attach (UpdateRoutingConfig — different Azure RBAC scope). - App Gateway / App Service auto-bind (V3-Pro auto-attach). - Soft-delete recovery (acm:RecoverDeletedCertificate-equivalent requires extra RBAC; V2 keeps minimum-permission surface). - GCP Certificate Manager (separate cloud, separate connector). Verified locally: - gofmt clean. - go vet ./internal/connector/target/azurekv/... ./internal/domain/... ./internal/service/... ./cmd/agent/... clean. - go test -short -count=1 ./internal/connector/target/azurekv/... ./cmd/agent/... green (all 16 supported target types instantiate via the agent factory). Reference: cowork/infisical-deep-research-results.md Part 5 Rank 5. Acquisition prompt: cowork/rank-5-aws-acm-azure-kv-target-adapters-prompt.md. Companion commit (AWS half):54033aa.
1717 lines
50 KiB
Go
1717 lines
50 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"io"
|
|
"log/slog"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestAgent_Heartbeat_Success tests that heartbeat sends correct metadata and handles 200 response.
|
|
func TestAgent_Heartbeat_Success(t *testing.T) {
|
|
// Create mock server to validate heartbeat request
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify correct endpoint and method
|
|
if r.URL.Path != "/api/v1/agents/a-test-agent/heartbeat" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("unexpected method: %s, expected POST", r.Method)
|
|
}
|
|
|
|
// Verify auth header
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer test-key" {
|
|
t.Errorf("unexpected auth header: %s", auth)
|
|
}
|
|
|
|
// Verify request body contains required fields
|
|
var payload map[string]string
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("failed to decode payload: %v", err)
|
|
}
|
|
|
|
// Check required fields
|
|
if _, ok := payload["version"]; !ok {
|
|
t.Error("missing version in heartbeat")
|
|
}
|
|
if _, ok := payload["hostname"]; !ok {
|
|
t.Error("missing hostname in heartbeat")
|
|
}
|
|
if _, ok := payload["os"]; !ok {
|
|
t.Error("missing os in heartbeat")
|
|
}
|
|
if _, ok := payload["architecture"]; !ok {
|
|
t.Error("missing architecture in heartbeat")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test-agent",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Should not panic
|
|
agent.sendHeartbeat(context.Background())
|
|
}
|
|
|
|
// TestAgent_Heartbeat_ServerError tests that heartbeat handles 500 response gracefully.
|
|
func TestAgent_Heartbeat_ServerError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("server error"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test-agent",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Should increment consecutive failures
|
|
failureBefore := agent.consecutiveFailures
|
|
agent.sendHeartbeat(context.Background())
|
|
failureAfter := agent.consecutiveFailures
|
|
|
|
if failureAfter != failureBefore+1 {
|
|
t.Errorf("expected consecutive failures to increment, got %d, want %d", failureAfter, failureBefore+1)
|
|
}
|
|
}
|
|
|
|
// TestAgent_Heartbeat_ConnectionError tests that heartbeat handles connection error.
|
|
func TestAgent_Heartbeat_ConnectionError(t *testing.T) {
|
|
// Use an invalid address that will fail immediately
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://invalid-host-that-does-not-exist.local:9999",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test-agent",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Should fail due to connection error
|
|
agent.sendHeartbeat(context.Background())
|
|
|
|
if agent.consecutiveFailures != 1 {
|
|
t.Errorf("expected consecutive failures to be 1, got %d", agent.consecutiveFailures)
|
|
}
|
|
}
|
|
|
|
// TestAgent_PollWork_NoWork tests that work polling handles empty work list.
|
|
func TestAgent_PollWork_NoWork(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agents/a-test-agent/work" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Method != http.MethodGet {
|
|
t.Errorf("unexpected method: %s", r.Method)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(WorkResponse{
|
|
Jobs: []JobItem{},
|
|
Count: 0,
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test-agent",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Should not panic
|
|
agent.pollForWork(context.Background())
|
|
}
|
|
|
|
// TestAgent_PollWork_Success tests that work polling parses and returns jobs correctly.
|
|
func TestAgent_PollWork_Success(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
workResp := WorkResponse{
|
|
Count: 2,
|
|
Jobs: []JobItem{
|
|
{
|
|
ID: "j-csr-001",
|
|
Type: "Issuance",
|
|
CertificateID: "mc-001",
|
|
CommonName: "example.com",
|
|
SANs: []string{"www.example.com"},
|
|
Status: "AwaitingCSR",
|
|
},
|
|
{
|
|
ID: "j-deploy-001",
|
|
Type: "Deployment",
|
|
CertificateID: "mc-001",
|
|
TargetID: strPtr("t-nginx-1"),
|
|
TargetType: "NGINX",
|
|
TargetConfig: json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`),
|
|
Status: "Pending",
|
|
},
|
|
},
|
|
}
|
|
|
|
json.NewEncoder(w).Encode(workResp)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test-agent",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Should not panic; work items are processed in separate gorines in real usage
|
|
agent.pollForWork(context.Background())
|
|
}
|
|
|
|
// TestSplitPEMChain tests PEM chain splitting into cert and chain.
|
|
func TestSplitPEMChain(t *testing.T) {
|
|
// Create two test certificates
|
|
cert1, _ := generateTestCertWithCN("cert1.example.com")
|
|
cert2, _ := generateTestCertWithCN("cert2.example.com")
|
|
|
|
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
|
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
|
|
|
cert1PEM := string(pem.EncodeToMemory(block1))
|
|
cert2PEM := string(pem.EncodeToMemory(block2))
|
|
|
|
chainPEM := cert1PEM + "\n" + cert2PEM
|
|
|
|
// Split
|
|
certOnly, chain := splitPEMChain(chainPEM)
|
|
|
|
// Verify cert part
|
|
if !bytes.Contains([]byte(certOnly), []byte("-----BEGIN CERTIFICATE-----")) {
|
|
t.Error("cert part missing BEGIN marker")
|
|
}
|
|
|
|
// Verify chain part
|
|
if !bytes.Contains([]byte(chain), []byte("-----BEGIN CERTIFICATE-----")) {
|
|
t.Error("chain part missing BEGIN marker")
|
|
}
|
|
|
|
// Verify they're different
|
|
if certOnly == chain {
|
|
t.Error("cert and chain should be different")
|
|
}
|
|
}
|
|
|
|
// TestSplitPEMChain_SingleCert tests PEM chain splitting with single certificate.
|
|
func TestSplitPEMChain_SingleCert(t *testing.T) {
|
|
cert, _ := generateTestCertWithCN("example.com")
|
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
certPEM := string(pem.EncodeToMemory(block))
|
|
|
|
certOnly, chain := splitPEMChain(certPEM)
|
|
|
|
if certOnly != certPEM {
|
|
t.Error("single cert should be returned as-is")
|
|
}
|
|
if chain != "" {
|
|
t.Error("chain should be empty for single cert")
|
|
}
|
|
}
|
|
|
|
// TestSplitPEMChain_InvalidPEM tests PEM chain splitting with invalid PEM.
|
|
func TestSplitPEMChain_InvalidPEM(t *testing.T) {
|
|
invalidPEM := "not a valid pem"
|
|
|
|
certOnly, chain := splitPEMChain(invalidPEM)
|
|
|
|
if certOnly != invalidPEM {
|
|
t.Error("invalid PEM should be returned as-is in cert part")
|
|
}
|
|
if chain != "" {
|
|
t.Error("chain should be empty for invalid PEM")
|
|
}
|
|
}
|
|
|
|
// TestParsePEMFile tests parsing a PEM file with certificates.
|
|
func TestParsePEMFile(t *testing.T) {
|
|
// Create a temporary file with a PEM certificate
|
|
tmpdir := t.TempDir()
|
|
certPath := filepath.Join(tmpdir, "cert.pem")
|
|
|
|
cert, _ := generateTestCert()
|
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
certPEM := pem.EncodeToMemory(block)
|
|
|
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
|
t.Fatalf("failed to write test cert: %v", err)
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Parse the file
|
|
entries := agent.parsePEMFile(certPath)
|
|
|
|
if len(entries) != 1 {
|
|
t.Errorf("expected 1 certificate, got %d", len(entries))
|
|
return
|
|
}
|
|
|
|
entry := entries[0]
|
|
if entry.CommonName != "test.example.com" {
|
|
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
|
|
}
|
|
if entry.SourceFormat != "PEM" {
|
|
t.Errorf("expected format 'PEM', got '%s'", entry.SourceFormat)
|
|
}
|
|
if entry.SourcePath != certPath {
|
|
t.Errorf("expected path '%s', got '%s'", certPath, entry.SourcePath)
|
|
}
|
|
|
|
// Verify fingerprint is non-empty and correct length (SHA256 hex = 64 chars)
|
|
if len(entry.FingerprintSHA256) != 64 {
|
|
t.Errorf("expected 64-char fingerprint, got %d", len(entry.FingerprintSHA256))
|
|
}
|
|
}
|
|
|
|
// TestParsePEMFile_MultipleCerts tests parsing a PEM file with multiple certificates.
|
|
func TestParsePEMFile_MultipleCerts(t *testing.T) {
|
|
tmpdir := t.TempDir()
|
|
certPath := filepath.Join(tmpdir, "chain.pem")
|
|
|
|
cert1, _ := generateTestCertWithCN("cert1.example.com")
|
|
cert2, _ := generateTestCertWithCN("cert2.example.com")
|
|
|
|
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
|
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
|
|
|
certPEM := append(pem.EncodeToMemory(block1), pem.EncodeToMemory(block2)...)
|
|
|
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
|
t.Fatalf("failed to write test cert: %v", err)
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
entries := agent.parsePEMFile(certPath)
|
|
|
|
if len(entries) != 2 {
|
|
t.Errorf("expected 2 certificates, got %d", len(entries))
|
|
}
|
|
}
|
|
|
|
// TestParseDERFile tests parsing a DER-encoded certificate file.
|
|
func TestParseDERFile(t *testing.T) {
|
|
tmpdir := t.TempDir()
|
|
derPath := filepath.Join(tmpdir, "cert.der")
|
|
|
|
cert, _ := generateTestCertWithCN("test.example.com")
|
|
if err := os.WriteFile(derPath, cert.Raw, 0644); err != nil {
|
|
t.Fatalf("failed to write test cert: %v", err)
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
entry, err := agent.parseDERFile(derPath)
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
return
|
|
}
|
|
|
|
if entry.CommonName != "test.example.com" {
|
|
t.Errorf("expected CN 'test.example.com', got '%s'", entry.CommonName)
|
|
}
|
|
if entry.SourceFormat != "DER" {
|
|
t.Errorf("expected format 'DER', got '%s'", entry.SourceFormat)
|
|
}
|
|
if len(entry.FingerprintSHA256) != 64 {
|
|
t.Errorf("expected 64-char fingerprint, got %d", len(entry.FingerprintSHA256))
|
|
}
|
|
}
|
|
|
|
// TestParseDERFile_Invalid tests parsing an invalid DER file.
|
|
func TestParseDERFile_Invalid(t *testing.T) {
|
|
tmpdir := t.TempDir()
|
|
derPath := filepath.Join(tmpdir, "invalid.der")
|
|
|
|
if err := os.WriteFile(derPath, []byte("not a valid der file"), 0644); err != nil {
|
|
t.Fatalf("failed to write test file: %v", err)
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
_, err := agent.parseDERFile(derPath)
|
|
if err == nil {
|
|
t.Error("expected error for invalid DER file")
|
|
}
|
|
}
|
|
|
|
// TestScanDirectory tests scanning a directory for certificate files.
|
|
func TestScanDirectory(t *testing.T) {
|
|
tmpdir := t.TempDir()
|
|
|
|
// Create subdirectory
|
|
subdir := filepath.Join(tmpdir, "subdir")
|
|
if err := os.MkdirAll(subdir, 0755); err != nil {
|
|
t.Fatalf("failed to create subdir: %v", err)
|
|
}
|
|
|
|
// Create certificates with various extensions
|
|
cert1, _ := generateTestCertWithCN("cert1.example.com")
|
|
cert2, _ := generateTestCertWithCN("cert2.example.com")
|
|
|
|
// Write cert1.pem
|
|
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
|
if err := os.WriteFile(filepath.Join(tmpdir, "cert1.pem"), pem.EncodeToMemory(block1), 0644); err != nil {
|
|
t.Fatalf("failed to write cert1: %v", err)
|
|
}
|
|
|
|
// Write cert2.crt in subdir
|
|
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
|
if err := os.WriteFile(filepath.Join(subdir, "cert2.crt"), pem.EncodeToMemory(block2), 0644); err != nil {
|
|
t.Fatalf("failed to write cert2: %v", err)
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
DiscoveryDirs: []string{tmpdir},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Simulate directory walk manually (as runDiscoveryScan does)
|
|
var certs []discoveredCertEntry
|
|
filepath.Walk(tmpdir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := filepath.Ext(path)
|
|
switch ext {
|
|
case ".pem", ".crt":
|
|
found := agent.parsePEMFile(path)
|
|
certs = append(certs, found...)
|
|
}
|
|
return nil
|
|
})
|
|
|
|
if len(certs) != 2 {
|
|
t.Errorf("expected 2 certificates from directory scan, got %d", len(certs))
|
|
}
|
|
}
|
|
|
|
// TestCreateTargetConnector_NGINX tests connector creation for NGINX target.
|
|
func TestCreateTargetConnector_NGINX(t *testing.T) {
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
configJSON := json.RawMessage(`{"cert_path":"/etc/nginx/cert.pem"}`)
|
|
connector, err := agent.createTargetConnector("NGINX", configJSON)
|
|
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if connector == nil {
|
|
t.Error("expected connector to be non-nil")
|
|
}
|
|
}
|
|
|
|
// TestCreateTargetConnector_Unsupported tests connector creation for unsupported type.
|
|
func TestCreateTargetConnector_Unsupported(t *testing.T) {
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
_, err := agent.createTargetConnector("UnsupportedType", nil)
|
|
|
|
if err == nil {
|
|
t.Error("expected error for unsupported target type")
|
|
}
|
|
}
|
|
|
|
// TestFetchCertificate_Success tests fetching a certificate from the control plane.
|
|
func TestFetchCertificate_Success(t *testing.T) {
|
|
cert, _ := generateTestCertWithCN("test.example.com")
|
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
expectedCertPEM := string(pem.EncodeToMemory(block))
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agents/a-test/certificates/mc-001" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]string{
|
|
"certificate_pem": expectedCertPEM,
|
|
})
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
certPEM, err := agent.fetchCertificate(context.Background(), "mc-001")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
if certPEM != expectedCertPEM {
|
|
t.Error("certificate PEM mismatch")
|
|
}
|
|
}
|
|
|
|
// TestFetchCertificate_NotFound tests fetching a non-existent certificate.
|
|
func TestFetchCertificate_NotFound(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte("not found"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
_, err := agent.fetchCertificate(context.Background(), "mc-nonexistent")
|
|
if err == nil {
|
|
t.Error("expected error for non-existent certificate")
|
|
}
|
|
}
|
|
|
|
// TestReportJobStatus_Success tests reporting job status to the control plane.
|
|
func TestReportJobStatus_Success(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agents/a-test/jobs/j-001/status" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("unexpected method: %s", r.Method)
|
|
}
|
|
|
|
var payload map[string]string
|
|
json.NewDecoder(r.Body).Decode(&payload)
|
|
|
|
if payload["status"] != "Completed" {
|
|
t.Errorf("expected status 'Completed', got '%s'", payload["status"])
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
err := agent.reportJobStatus(context.Background(), "j-001", "Completed", "")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReportJobStatus_WithError tests reporting job status with error message.
|
|
func TestReportJobStatus_WithError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var payload map[string]string
|
|
json.NewDecoder(r.Body).Decode(&payload)
|
|
|
|
if payload["status"] != "Failed" {
|
|
t.Errorf("expected status 'Failed', got '%s'", payload["status"])
|
|
}
|
|
if payload["error"] != "deployment failed" {
|
|
t.Errorf("expected error 'deployment failed', got '%s'", payload["error"])
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
err := agent.reportJobStatus(context.Background(), "j-001", "Failed", "deployment failed")
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMakeRequest_Success tests making an authenticated HTTP request.
|
|
func TestMakeRequest_Success(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify auth header
|
|
auth := r.Header.Get("Authorization")
|
|
if auth != "Bearer test-key" {
|
|
t.Errorf("unexpected auth: %s", auth)
|
|
}
|
|
|
|
// Verify content-type
|
|
ct := r.Header.Get("Content-Type")
|
|
if ct != "application/json" {
|
|
t.Errorf("unexpected content-type: %s", ct)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
resp, err := agent.makeRequest(context.Background(), http.MethodPost, "/test", map[string]string{"key": "value"})
|
|
if err != nil {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Errorf("unexpected status: %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// TestMakeRequest_InvalidURL tests making a request with invalid URL.
|
|
func TestMakeRequest_InvalidURL(t *testing.T) {
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://invalid-host-that-does-not-exist.local:9999",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
_, err := agent.makeRequest(context.Background(), http.MethodGet, "/test", nil)
|
|
if err == nil {
|
|
t.Error("expected error for unreachable host")
|
|
}
|
|
}
|
|
|
|
// TestCertKeyInfo tests extraction of key algorithm and size from certificates.
|
|
func TestCertKeyInfo(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
genKey func() interface{}
|
|
expectedAlg string
|
|
minBitSize int
|
|
}{
|
|
{
|
|
name: "ECDSA P-256",
|
|
genKey: func() interface{} {
|
|
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
return key.Public()
|
|
},
|
|
expectedAlg: "ECDSA",
|
|
minBitSize: 256,
|
|
},
|
|
{
|
|
name: "RSA 2048",
|
|
genKey: func() interface{} {
|
|
key, _ := rsa.GenerateKey(rand.Reader, 2048)
|
|
return key.Public()
|
|
},
|
|
expectedAlg: "RSA",
|
|
minBitSize: 2048,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
pubKey := tt.genKey()
|
|
|
|
// Create certificate with this key
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
CommonName: "test.com",
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
BasicConstraintsValid: true,
|
|
}
|
|
|
|
var privKey interface{}
|
|
if ecdsaPub, ok := pubKey.(*ecdsa.PublicKey); ok {
|
|
key, _ := ecdsa.GenerateKey(ecdsaPub.Curve, rand.Reader)
|
|
privKey = key
|
|
} else if rsaPub, ok := pubKey.(*rsa.PublicKey); ok {
|
|
key, _ := rsa.GenerateKey(rand.Reader, rsaPub.N.BitLen())
|
|
privKey = key
|
|
}
|
|
|
|
certDER, _ := x509.CreateCertificate(rand.Reader, template, template, pubKey, privKey)
|
|
cert, _ := x509.ParseCertificate(certDER)
|
|
|
|
alg, bitSize := certKeyInfo(cert)
|
|
if alg != tt.expectedAlg {
|
|
t.Errorf("expected algorithm %s, got %s", tt.expectedAlg, alg)
|
|
}
|
|
if bitSize < tt.minBitSize {
|
|
t.Errorf("expected bitsize >= %d, got %d", tt.minBitSize, bitSize)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNewAgent tests agent initialization.
|
|
func TestNewAgent(t *testing.T) {
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
if agent.config != cfg {
|
|
t.Error("config not set correctly")
|
|
}
|
|
if agent.heartbeatInterval != 60*time.Second {
|
|
t.Errorf("expected heartbeat interval 60s, got %v", agent.heartbeatInterval)
|
|
}
|
|
if agent.pollInterval != 30*time.Second {
|
|
t.Errorf("expected poll interval 30s, got %v", agent.pollInterval)
|
|
}
|
|
if agent.client == nil {
|
|
t.Error("HTTP client not initialized")
|
|
}
|
|
}
|
|
|
|
// TestNewAgent_WithLogger tests agent initialization with logger.
|
|
func TestNewAgent_WithLogger(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
if agent.logger != logger {
|
|
t.Error("logger not set correctly")
|
|
}
|
|
}
|
|
|
|
// Helper to create test certificates with specific CN
|
|
func generateTestCertWithCN(commonName string) (*x509.Certificate, error) {
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
template := &x509.Certificate{
|
|
SerialNumber: big.NewInt(1),
|
|
Subject: pkix.Name{
|
|
CommonName: commonName,
|
|
},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().Add(24 * time.Hour),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
BasicConstraintsValid: true,
|
|
DNSNames: []string{commonName},
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return x509.ParseCertificate(certDER)
|
|
}
|
|
|
|
// Helper to create string pointer
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
// TestCreateTargetConnector_AllSupportedTypes tests connector creation for all 16 supported target types.
|
|
func TestCreateTargetConnector_AllSupportedTypes(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
tests := []struct {
|
|
name string
|
|
typeName string
|
|
config interface{}
|
|
}{
|
|
{
|
|
name: "NGINX",
|
|
typeName: "NGINX",
|
|
config: map[string]string{
|
|
"cert_path": filepath.Join(tmpDir, "cert.pem"),
|
|
"key_path": filepath.Join(tmpDir, "key.pem"),
|
|
},
|
|
},
|
|
{
|
|
name: "Apache",
|
|
typeName: "Apache",
|
|
config: map[string]string{
|
|
"cert_path": filepath.Join(tmpDir, "cert.pem"),
|
|
"key_path": filepath.Join(tmpDir, "key.pem"),
|
|
},
|
|
},
|
|
{
|
|
name: "HAProxy",
|
|
typeName: "HAProxy",
|
|
config: map[string]string{
|
|
"cert_path": filepath.Join(tmpDir, "cert.pem"),
|
|
},
|
|
},
|
|
{
|
|
name: "F5",
|
|
typeName: "F5",
|
|
config: map[string]string{
|
|
"host": "192.0.2.1",
|
|
},
|
|
},
|
|
{
|
|
name: "IIS",
|
|
typeName: "IIS",
|
|
config: map[string]string{
|
|
"cert_store": "My",
|
|
},
|
|
},
|
|
{
|
|
name: "Traefik",
|
|
typeName: "Traefik",
|
|
config: map[string]string{
|
|
"cert_dir": tmpDir,
|
|
},
|
|
},
|
|
{
|
|
name: "Caddy",
|
|
typeName: "Caddy",
|
|
config: map[string]string{
|
|
"mode": "file",
|
|
},
|
|
},
|
|
{
|
|
name: "Envoy",
|
|
typeName: "Envoy",
|
|
config: map[string]string{
|
|
"cert_dir": tmpDir,
|
|
},
|
|
},
|
|
{
|
|
name: "Postfix",
|
|
typeName: "Postfix",
|
|
config: map[string]string{
|
|
"cert_path": filepath.Join(tmpDir, "cert.pem"),
|
|
"key_path": filepath.Join(tmpDir, "key.pem"),
|
|
},
|
|
},
|
|
{
|
|
name: "Dovecot",
|
|
typeName: "Dovecot",
|
|
config: map[string]string{
|
|
"cert_path": filepath.Join(tmpDir, "cert.pem"),
|
|
"key_path": filepath.Join(tmpDir, "key.pem"),
|
|
},
|
|
},
|
|
{
|
|
name: "SSH",
|
|
typeName: "SSH",
|
|
config: map[string]string{
|
|
"host": "192.0.2.1",
|
|
"user": "root",
|
|
"cert_path": "/etc/ssl/cert.pem",
|
|
"key_path": "/etc/ssl/key.pem",
|
|
},
|
|
},
|
|
{
|
|
name: "WinCertStore",
|
|
typeName: "WinCertStore",
|
|
config: map[string]string{
|
|
"cert_store": "My",
|
|
},
|
|
},
|
|
{
|
|
name: "JavaKeystore",
|
|
typeName: "JavaKeystore",
|
|
config: map[string]string{
|
|
"keystore_path": filepath.Join(tmpDir, "keystore.jks"),
|
|
},
|
|
},
|
|
{
|
|
name: "KubernetesSecrets",
|
|
typeName: "KubernetesSecrets",
|
|
config: map[string]string{
|
|
"namespace": "default",
|
|
"secret_name": "tls-secret",
|
|
},
|
|
},
|
|
{
|
|
// Rank 5 of the 2026-05-03 Infisical deep-research deliverable.
|
|
// Region must be a valid AWS region; the connector lazy-loads
|
|
// the SDK client during ValidateConfig but New() with a populated
|
|
// region should succeed against the SDK credential chain
|
|
// (LoadDefaultConfig doesn't require live creds).
|
|
name: "AWSACM",
|
|
typeName: "AWSACM",
|
|
config: map[string]string{
|
|
"region": "us-east-1",
|
|
},
|
|
},
|
|
{
|
|
// Rank 5 (Azure half). Vault URL + cert name; the SDK client
|
|
// lazy-loads via DefaultAzureCredential which doesn't require
|
|
// live creds at construction time.
|
|
name: "AzureKeyVault",
|
|
typeName: "AzureKeyVault",
|
|
config: map[string]string{
|
|
"vault_url": "https://test-vault.vault.azure.net",
|
|
"certificate_name": "demo-cert",
|
|
},
|
|
},
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
configJSON, err := json.Marshal(tt.config)
|
|
if err != nil {
|
|
t.Fatalf("failed to marshal config: %v", err)
|
|
}
|
|
|
|
connector, err := agent.createTargetConnector(tt.typeName, configJSON)
|
|
|
|
// Some connectors (like WinCertStore, IIS) may error on non-Windows platforms
|
|
// or with insufficient validation. We accept either a valid connector or an error
|
|
// for now — the real unit tests in internal/connector/target/* cover validation
|
|
if connector == nil && err != nil {
|
|
// This is acceptable if the connector validates required fields
|
|
t.Logf("connector creation returned error (may be validation): %v", err)
|
|
return
|
|
}
|
|
|
|
if connector == nil {
|
|
t.Errorf("expected connector to be non-nil for type %s", tt.typeName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateTargetConnector_InvalidJSON tests connector creation with invalid JSON for each type.
|
|
func TestCreateTargetConnector_InvalidJSON(t *testing.T) {
|
|
tests := []string{
|
|
"NGINX",
|
|
"Apache",
|
|
"HAProxy",
|
|
"F5",
|
|
"IIS",
|
|
"Traefik",
|
|
"Caddy",
|
|
"Envoy",
|
|
"Postfix",
|
|
"Dovecot",
|
|
"SSH",
|
|
"WinCertStore",
|
|
"JavaKeystore",
|
|
"KubernetesSecrets",
|
|
"AWSACM",
|
|
"AzureKeyVault",
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
invalidJSON := json.RawMessage("{invalid json}")
|
|
|
|
for _, typeName := range tests {
|
|
t.Run(typeName, func(t *testing.T) {
|
|
_, err := agent.createTargetConnector(typeName, invalidJSON)
|
|
|
|
if err == nil {
|
|
t.Errorf("expected error for invalid JSON with type %s", typeName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCreateTargetConnector_UnknownType tests connector creation with unknown target type.
|
|
func TestCreateTargetConnector_UnknownType(t *testing.T) {
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
_, err := agent.createTargetConnector("MagicBox", nil)
|
|
|
|
if err == nil {
|
|
t.Error("expected error for unsupported target type")
|
|
}
|
|
if !strings.Contains(err.Error(), "unsupported target type") {
|
|
t.Errorf("expected 'unsupported target type' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestCreateTargetConnector_EmptyConfig tests connector creation with empty config JSON.
|
|
func TestCreateTargetConnector_EmptyConfig(t *testing.T) {
|
|
tests := []string{
|
|
"NGINX",
|
|
"Apache",
|
|
"HAProxy",
|
|
"Traefik",
|
|
"Caddy",
|
|
"Envoy",
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
for _, typeName := range tests {
|
|
t.Run(typeName, func(t *testing.T) {
|
|
// Empty config should be handled gracefully (defaults applied)
|
|
connector, err := agent.createTargetConnector(typeName, nil)
|
|
|
|
// Should not error on nil/empty config (defaults are applied)
|
|
if err != nil {
|
|
// Validation errors are acceptable, but parsing errors are not
|
|
if !strings.Contains(err.Error(), "invalid") && !strings.Contains(err.Error(), "missing") {
|
|
t.Logf("connector creation with empty config returned: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if connector == nil {
|
|
t.Errorf("expected non-nil connector for type %s with empty config", typeName)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestRunDiscoveryScan_ValidCerts tests discovery scanning with valid certificates.
|
|
func TestRunDiscoveryScan_ValidCerts(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a valid PEM certificate file
|
|
cert, _ := generateTestCertWithCN("example.com")
|
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
certPEM := pem.EncodeToMemory(block)
|
|
|
|
certPath := filepath.Join(tmpDir, "cert.pem")
|
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
|
t.Fatalf("failed to write certificate: %v", err)
|
|
}
|
|
|
|
// Mock server to accept discovery report
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agents/a-test/discoveries" {
|
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("unexpected method: %s", r.Method)
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
// Verify request body
|
|
var payload map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
t.Logf("failed to decode discovery report: %v", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Verify report contains certificates
|
|
certs, ok := payload["certificates"].([]interface{})
|
|
if !ok || len(certs) == 0 {
|
|
t.Logf("expected certificates in report")
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
DiscoveryDirs: []string{tmpDir},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Run discovery scan
|
|
agent.runDiscoveryScan(context.Background())
|
|
|
|
// If we got here without panic/error, the test passes
|
|
}
|
|
|
|
// TestRunDiscoveryScan_NoCertificates tests discovery scanning with empty directory.
|
|
func TestRunDiscoveryScan_NoCertificates(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create an empty directory
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Should not receive a request if no certs found and no errors
|
|
t.Logf("discovery report received: %s", r.URL.Path)
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
DiscoveryDirs: []string{tmpDir},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Run discovery scan - should complete without error even with empty directory
|
|
agent.runDiscoveryScan(context.Background())
|
|
}
|
|
|
|
// TestRunDiscoveryScan_MultipleCerts tests discovery scanning with multiple certificate files.
|
|
func TestRunDiscoveryScan_MultipleCerts(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create multiple certificate files
|
|
cert1, _ := generateTestCertWithCN("cert1.example.com")
|
|
cert2, _ := generateTestCertWithCN("cert2.example.com")
|
|
|
|
block1 := &pem.Block{Type: "CERTIFICATE", Bytes: cert1.Raw}
|
|
block2 := &pem.Block{Type: "CERTIFICATE", Bytes: cert2.Raw}
|
|
|
|
certPath1 := filepath.Join(tmpDir, "cert1.pem")
|
|
certPath2 := filepath.Join(tmpDir, "cert2.crt")
|
|
|
|
if err := os.WriteFile(certPath1, pem.EncodeToMemory(block1), 0644); err != nil {
|
|
t.Fatalf("failed to write cert1: %v", err)
|
|
}
|
|
if err := os.WriteFile(certPath2, pem.EncodeToMemory(block2), 0644); err != nil {
|
|
t.Fatalf("failed to write cert2: %v", err)
|
|
}
|
|
|
|
certCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agents/a-test/discoveries" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Count certificates in report
|
|
if certs, ok := payload["certificates"].([]interface{}); ok {
|
|
certCount = len(certs)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
DiscoveryDirs: []string{tmpDir},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Run discovery scan
|
|
agent.runDiscoveryScan(context.Background())
|
|
|
|
if certCount != 2 {
|
|
t.Logf("expected 2 certificates in discovery report, got %d", certCount)
|
|
}
|
|
}
|
|
|
|
// TestRunDiscoveryScan_DERCertificate tests discovery scanning with DER-encoded certificate.
|
|
func TestRunDiscoveryScan_DERCertificate(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a DER-encoded certificate file
|
|
cert, _ := generateTestCertWithCN("der.example.com")
|
|
derPath := filepath.Join(tmpDir, "cert.der")
|
|
|
|
if err := os.WriteFile(derPath, cert.Raw, 0644); err != nil {
|
|
t.Fatalf("failed to write DER certificate: %v", err)
|
|
}
|
|
|
|
certCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agents/a-test/discoveries" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if certs, ok := payload["certificates"].([]interface{}); ok {
|
|
certCount = len(certs)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
DiscoveryDirs: []string{tmpDir},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Run discovery scan
|
|
agent.runDiscoveryScan(context.Background())
|
|
|
|
if certCount != 1 {
|
|
t.Logf("expected 1 DER certificate in discovery report, got %d", certCount)
|
|
}
|
|
}
|
|
|
|
// TestRunDiscoveryScan_Subdirectories tests discovery scanning with subdirectories.
|
|
func TestRunDiscoveryScan_Subdirectories(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create subdirectory
|
|
subDir := filepath.Join(tmpDir, "subdir")
|
|
if err := os.MkdirAll(subDir, 0755); err != nil {
|
|
t.Fatalf("failed to create subdir: %v", err)
|
|
}
|
|
|
|
// Create certificate in subdirectory
|
|
cert, _ := generateTestCertWithCN("subdir.example.com")
|
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
certPath := filepath.Join(subDir, "cert.pem")
|
|
|
|
if err := os.WriteFile(certPath, pem.EncodeToMemory(block), 0644); err != nil {
|
|
t.Fatalf("failed to write certificate: %v", err)
|
|
}
|
|
|
|
certCount := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/api/v1/agents/a-test/discoveries" {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
var payload map[string]interface{}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if certs, ok := payload["certificates"].([]interface{}); ok {
|
|
certCount = len(certs)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
DiscoveryDirs: []string{tmpDir},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Run discovery scan - should recursively find certs in subdirs
|
|
agent.runDiscoveryScan(context.Background())
|
|
|
|
if certCount != 1 {
|
|
t.Logf("expected 1 certificate in subdirectory, got %d", certCount)
|
|
}
|
|
}
|
|
|
|
// TestRunDiscoveryScan_ServerError tests discovery scanning when server returns error.
|
|
func TestRunDiscoveryScan_ServerError(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create a certificate file
|
|
cert, _ := generateTestCertWithCN("example.com")
|
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
certPath := filepath.Join(tmpDir, "cert.pem")
|
|
|
|
if err := os.WriteFile(certPath, pem.EncodeToMemory(block), 0644); err != nil {
|
|
t.Fatalf("failed to write certificate: %v", err)
|
|
}
|
|
|
|
// Mock server returns error
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("server error"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
DiscoveryDirs: []string{tmpDir},
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
// Should handle server error gracefully without panicking
|
|
agent.runDiscoveryScan(context.Background())
|
|
}
|
|
|
|
// TestDiscoveredCertEntry_ValidFields tests that discovered certificate entries have valid fields.
|
|
func TestDiscoveredCertEntry_ValidFields(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
|
|
// Create certificate with specific details
|
|
cert, _ := generateTestCertWithCN("test.example.com")
|
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}
|
|
certPEM := pem.EncodeToMemory(block)
|
|
|
|
certPath := filepath.Join(tmpDir, "cert.pem")
|
|
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
|
t.Fatalf("failed to write certificate: %v", err)
|
|
}
|
|
|
|
cfg := &AgentConfig{
|
|
ServerURL: "http://localhost:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, _ := NewAgent(cfg, logger)
|
|
|
|
entries := agent.parsePEMFile(certPath)
|
|
|
|
if len(entries) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(entries))
|
|
}
|
|
|
|
entry := entries[0]
|
|
|
|
// Verify all required fields are populated
|
|
if entry.CommonName == "" {
|
|
t.Error("CommonName should not be empty")
|
|
}
|
|
if entry.FingerprintSHA256 == "" {
|
|
t.Error("FingerprintSHA256 should not be empty")
|
|
}
|
|
if len(entry.FingerprintSHA256) != 64 {
|
|
t.Errorf("FingerprintSHA256 should be 64 hex chars, got %d", len(entry.FingerprintSHA256))
|
|
}
|
|
if entry.SerialNumber == "" {
|
|
t.Error("SerialNumber should not be empty")
|
|
}
|
|
if entry.IssuerDN == "" {
|
|
t.Error("IssuerDN should not be empty")
|
|
}
|
|
if entry.SubjectDN == "" {
|
|
t.Error("SubjectDN should not be empty")
|
|
}
|
|
if entry.NotBefore == "" {
|
|
t.Error("NotBefore should not be empty")
|
|
}
|
|
if entry.NotAfter == "" {
|
|
t.Error("NotAfter should not be empty")
|
|
}
|
|
if entry.KeyAlgorithm == "" {
|
|
t.Error("KeyAlgorithm should not be empty")
|
|
}
|
|
if entry.KeySize == 0 {
|
|
t.Error("KeySize should not be zero")
|
|
}
|
|
if entry.SourcePath == "" {
|
|
t.Error("SourcePath should not be empty")
|
|
}
|
|
if entry.SourceFormat != "PEM" {
|
|
t.Errorf("SourceFormat should be 'PEM', got '%s'", entry.SourceFormat)
|
|
}
|
|
if entry.PEMData == "" {
|
|
t.Error("PEMData should not be empty")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTPS-Everywhere milestone (v2.2, §3.2 / §7) — Phase 5 client-side tests.
|
|
//
|
|
// These tests pin the agent's pre-flight HTTPS-scheme guard and the TLS
|
|
// configuration surface (CA bundle loading + TLS 1.3 round-trip) so that
|
|
// regressions surface at unit-test time, not at the first heartbeat of a
|
|
// production rollout. Matches the same contract asserted by the sibling
|
|
// binaries cmd/cli/main_test.go and cmd/mcp-server/main_test.go — the three
|
|
// must stay in lock-step because all three are HTTPS-only clients of the
|
|
// same control plane.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// TestValidateHTTPSScheme pins the pre-flight URL-scheme guard that the
|
|
// HTTPS-Everywhere milestone requires on the agent binary startup path. The
|
|
// agent's diagnostic is distinct from the CLI/MCP variants because it names
|
|
// CERTCTL_SERVER_URL (the only input channel — no --server flag on the
|
|
// agent). Every case here mirrors the dispatch arms in cmd/agent/main.go:
|
|
// validateHTTPSScheme; drifting the error-message substrings is what this
|
|
// test is here to catch.
|
|
func TestValidateHTTPSScheme(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
serverURL string
|
|
wantErr bool
|
|
wantErrSub string
|
|
}{
|
|
{
|
|
name: "https URL passes",
|
|
serverURL: "https://certctl-server:8443",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "https URL with path passes",
|
|
serverURL: "https://certctl.example.com/api/v1",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "uppercase HTTPS scheme passes (url.Parse lowercases)",
|
|
serverURL: "HTTPS://certctl-server:8443",
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "empty URL rejected names CERTCTL_SERVER_URL",
|
|
serverURL: "",
|
|
wantErr: true,
|
|
wantErrSub: "CERTCTL_SERVER_URL is empty",
|
|
},
|
|
{
|
|
name: "plaintext http rejected",
|
|
serverURL: "http://certctl-server:8443",
|
|
wantErr: true,
|
|
wantErrSub: "plaintext http://",
|
|
},
|
|
{
|
|
name: "bare host missing scheme falls through to unsupported",
|
|
serverURL: "localhost:8443",
|
|
wantErr: true,
|
|
// url.Parse treats "localhost:8443" as scheme=localhost,
|
|
// opaque=8443 — exercises the default arm (unsupported scheme)
|
|
// rather than the empty-scheme arm. Both are fail-closed, which
|
|
// is what we care about.
|
|
wantErrSub: "unsupported scheme",
|
|
},
|
|
{
|
|
name: "path-only URL rejected",
|
|
serverURL: "//certctl-server:8443",
|
|
wantErr: true,
|
|
wantErrSub: "missing a scheme",
|
|
},
|
|
{
|
|
name: "unsupported scheme rejected",
|
|
serverURL: "ftp://certctl-server:8443",
|
|
wantErr: true,
|
|
wantErrSub: "unsupported scheme",
|
|
},
|
|
{
|
|
name: "ws scheme rejected",
|
|
serverURL: "ws://certctl-server:8443",
|
|
wantErr: true,
|
|
wantErrSub: "unsupported scheme",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateHTTPSScheme(tt.serverURL)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Fatalf("validateHTTPSScheme(%q) err=%v wantErr=%v", tt.serverURL, err, tt.wantErr)
|
|
}
|
|
if tt.wantErr && tt.wantErrSub != "" && !strings.Contains(err.Error(), tt.wantErrSub) {
|
|
t.Errorf("validateHTTPSScheme(%q) err=%q must contain %q so operators see the right diagnostic",
|
|
tt.serverURL, err.Error(), tt.wantErrSub)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// writeTestCABundle PEM-encodes a cert's DER bytes and writes the result to a
|
|
// tmp file inside dir. Used by CA-bundle tests so each case owns a distinct
|
|
// file path (matters for the "missing file" case which must point at a path
|
|
// that provably does not exist). Returns the path.
|
|
func writeTestCABundle(t *testing.T, dir string, certDER []byte, filename string) string {
|
|
t.Helper()
|
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
|
path := filepath.Join(dir, filename)
|
|
if err := os.WriteFile(path, pemBytes, 0644); err != nil {
|
|
t.Fatalf("writing CA bundle %q: %v", path, err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// TestNewAgent_CABundle_Success confirms that a well-formed PEM bundle gets
|
|
// parsed into an x509.CertPool and wired onto the agent's HTTP client
|
|
// transport. This is the happy path the docs/tls.md "Private CA signed
|
|
// server cert" section depends on.
|
|
func TestNewAgent_CABundle_Success(t *testing.T) {
|
|
cert, err := generateTestCertWithCN("test.certctl.local")
|
|
if err != nil {
|
|
t.Fatalf("generateTestCertWithCN: %v", err)
|
|
}
|
|
bundlePath := writeTestCABundle(t, t.TempDir(), cert.Raw, "ca-bundle.pem")
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, err := NewAgent(&AgentConfig{
|
|
ServerURL: "https://certctl-server:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
CABundlePath: bundlePath,
|
|
}, logger)
|
|
if err != nil {
|
|
t.Fatalf("NewAgent with valid CA bundle err=%v want nil", err)
|
|
}
|
|
|
|
transport, ok := agent.client.Transport.(*http.Transport)
|
|
if !ok {
|
|
t.Fatalf("agent.client.Transport is %T; want *http.Transport", agent.client.Transport)
|
|
}
|
|
if transport.TLSClientConfig == nil {
|
|
t.Fatal("TLSClientConfig is nil; HTTPS-everywhere milestone requires a non-nil TLS config")
|
|
}
|
|
if transport.TLSClientConfig.MinVersion != tls.VersionTLS13 {
|
|
t.Errorf("MinVersion=%x want TLS 1.3 (%x) per §2.3 of the milestone spec",
|
|
transport.TLSClientConfig.MinVersion, tls.VersionTLS13)
|
|
}
|
|
if transport.TLSClientConfig.RootCAs == nil {
|
|
t.Error("RootCAs is nil; the configured CA bundle was silently dropped")
|
|
}
|
|
}
|
|
|
|
// TestNewAgent_CABundle_MissingFile pins the fail-loud behavior when the
|
|
// operator points CERTCTL_SERVER_CA_BUNDLE_PATH at a path that does not
|
|
// exist. Falling back to system roots here would mask a misconfiguration as
|
|
// a much harder-to-debug TLS handshake failure downstream.
|
|
func TestNewAgent_CABundle_MissingFile(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
missingPath := filepath.Join(t.TempDir(), "does-not-exist.pem")
|
|
_, err := NewAgent(&AgentConfig{
|
|
ServerURL: "https://certctl-server:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
CABundlePath: missingPath,
|
|
}, logger)
|
|
if err == nil {
|
|
t.Fatal("NewAgent err=nil for missing CA bundle path; must fail loud at startup")
|
|
}
|
|
if !strings.Contains(err.Error(), "reading CA bundle") {
|
|
t.Errorf("err=%q must contain \"reading CA bundle\" so operators can trace the cause", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestNewAgent_CABundle_EmptyPEM covers the "file exists but contains no
|
|
// valid certs" case (garbage, wrong-format, stripped PEM). AppendCertsFromPEM
|
|
// returns false in this case; NewAgent must translate that into a fail-loud
|
|
// startup error rather than quietly carry on with an empty pool.
|
|
func TestNewAgent_CABundle_EmptyPEM(t *testing.T) {
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
bundlePath := filepath.Join(t.TempDir(), "empty.pem")
|
|
if err := os.WriteFile(bundlePath, []byte("not a pem-encoded certificate, just garbage\n"), 0644); err != nil {
|
|
t.Fatalf("writing garbage bundle: %v", err)
|
|
}
|
|
_, err := NewAgent(&AgentConfig{
|
|
ServerURL: "https://certctl-server:8443",
|
|
APIKey: "test-key",
|
|
AgentID: "a-test",
|
|
Hostname: "test-host",
|
|
CABundlePath: bundlePath,
|
|
}, logger)
|
|
if err == nil {
|
|
t.Fatal("NewAgent err=nil for empty-PEM CA bundle; must fail loud at startup")
|
|
}
|
|
if !strings.Contains(err.Error(), "no valid PEM-encoded certificates") {
|
|
t.Errorf("err=%q must contain \"no valid PEM-encoded certificates\" so operators see why the bundle was rejected", err.Error())
|
|
}
|
|
}
|
|
|
|
// TestNewAgent_TLSRoundTrip is the end-to-end integration-style check: spin
|
|
// up an httptest.NewTLSServer (which presents a self-signed cert over TLS
|
|
// 1.3), feed that cert into the agent as a CA bundle, and confirm the agent
|
|
// successfully completes a heartbeat round-trip over HTTPS. This proves that
|
|
// (a) the CA pool is actually being consulted during verification and (b)
|
|
// the TLS 1.3 MinVersion doesn't break against httptest's default
|
|
// negotiation. Equivalent to the "TLS handshake succeeds against a
|
|
// self-signed control plane" integration gate, but runs in-process with no
|
|
// Docker dependency.
|
|
func TestNewAgent_TLSRoundTrip(t *testing.T) {
|
|
var heartbeatHit int
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/api/v1/agents/a-tls-test/heartbeat" && r.Method == http.MethodPost {
|
|
heartbeatHit++
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer server.Close()
|
|
|
|
// server.Certificate() returns the *x509.Certificate httptest presents;
|
|
// PEM-encode its DER bytes so NewAgent's AppendCertsFromPEM can ingest it.
|
|
bundlePath := writeTestCABundle(t, t.TempDir(), server.Certificate().Raw, "httptest-ca.pem")
|
|
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
agent, err := NewAgent(&AgentConfig{
|
|
ServerURL: server.URL,
|
|
APIKey: "test-key",
|
|
AgentID: "a-tls-test",
|
|
Hostname: "tls-test-host",
|
|
CABundlePath: bundlePath,
|
|
}, logger)
|
|
if err != nil {
|
|
t.Fatalf("NewAgent with httptest CA bundle err=%v want nil", err)
|
|
}
|
|
|
|
agent.sendHeartbeat(context.Background())
|
|
|
|
if heartbeatHit != 1 {
|
|
t.Fatalf("heartbeat handler hit %d times; want 1 — the TLS round-trip must actually complete", heartbeatHit)
|
|
}
|
|
}
|