Files
certctl/cmd/agent/verify_test.go
T
shankar0123 52248be717 v2.0.47: HTTPS Everywhere — TLS-only control plane, agents/CLI/MCP
Breaking change release. Plaintext HTTP listener removed. The certctl
control plane now terminates TLS 1.3 on :8443 via
http.Server.ListenAndServeTLS. No CERTCTL_TLS_ENABLED=false escape
hatch. No dual-listener mode. One-step cutover per docs/upgrade-to-tls.md.

Server
- cmd/server/tls.go: certHolder with SIGHUP hot-reload + atomic cert
  swap, buildServerTLSConfig (TLS 1.3 min, GetCertificate callback),
  preflightServerTLS validation
- cmd/server/main.go: ListenAndServeTLS in place of ListenAndServe,
  watchSIGHUP wiring, cert/key path config threading
- tls_test.go: 418-line regression coverage of reload, preflight,
  callback behavior, SAN validation

Config
- CERTCTL_TLS_CERT_PATH / CERTCTL_TLS_KEY_PATH (required)
- Plaintext rejection: agents/CLI/MCP pre-flight-fail on http://
  URLs with a pointer to docs/upgrade-to-tls.md

Agents, CLI, MCP
- All three pre-flight-reject http:// URLs with fail-loud diagnostic
- CERTCTL_SERVER_CA_BUNDLE_PATH for private-CA trust
- CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY for dev-only bypass
  (loud warning on startup)
- install-agent.sh emits both vars as commented template lines

docker-compose
- certctl-tls-init sidecar generates SAN-valid self-signed cert into
  deploy/test/certs/ on first boot
- All demo-stack curls pin against ca.crt with --cacert

Helm chart
- Three TLS provisioning modes, exactly one required:
  - server.tls.existingSecret (operator-supplied)
  - server.tls.certManager.enabled (cert-manager integration)
  - server.tls.selfSigned.enabled (eval only — not for production)
- server-certificate.yaml template for cert-manager mode
- helm install without a TLS source fails at template render with
  a pointer to docs/tls.md

CI
- .github/workflows/ci.yml Helm Chart Validation step renders the
  chart in both existingSecret and cert-manager modes, plus an
  inverse guard-regression test that asserts helm template MUST
  refuse to render when no TLS source is configured. Previously
  the single `helm template` invocation hit the certctl.tls.required
  fail-loud guard and exit-1'd CI. Four invocations now: lint
  (existingSecret), template (existingSecret), template
  (cert-manager), template (no args — must fail).

Integration tests
- deploy/test/integration_test.go stands up the Compose stack over
  HTTPS, extracts the CA bundle, and exercises every certctl API
  over https://localhost:8443
- All 34 integration subtests green (per Phase 8 local CI-parity)

Documentation
- New: docs/tls.md (provisioning patterns, rotation, SIGHUP reload)
- New: docs/upgrade-to-tls.md (one-step cutover, no-downgrade
  warnings, fleet-roll sequencing)
- CHANGELOG.md: v2.2.0 "HTTPS Everywhere — The Irony" entry
  (file heading unchanged; release tag is v2.0.47)
- All curls in docs/, examples/, deploy/helm/ guides use
  https://localhost:8443 --cacert

Verification
- grep -rn "ListenAndServe[^T]" cmd/ internal/ → 0 hits
- grep -rn "\"http://" cmd/ internal/ → 2 benign hits (Caddy admin
  API default, SSRF doc comment) — zero certctl endpoints
- Tasks #197–#206 (Phases 0–8) all closed in the tracker

Files: 65 changed, 3489 insertions, 372 deletions (pre-CI-fix).
2026-04-20 03:43:10 +00:00

432 lines
11 KiB
Go

package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestComputeCertificateFingerprint(t *testing.T) {
// Generate a test certificate for fingerprint validation
cert, err := generateTestCert()
if err != nil {
t.Fatalf("failed to generate test cert: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}))
fp, err := computeCertificateFingerprint(certPEM)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if len(fp) != 64 { // SHA256 hex = 64 chars
t.Errorf("expected 64 char fingerprint, got %d", len(fp))
}
}
func TestComputeCertificateFingerprint_InvalidPEM(t *testing.T) {
_, err := computeCertificateFingerprint("not a valid pem")
if err == nil {
t.Error("expected error for invalid PEM")
}
}
func TestComputeCertificateFingerprint_EmptyString(t *testing.T) {
_, err := computeCertificateFingerprint("")
if err == nil {
t.Error("expected error for empty string")
}
}
func TestExtractTargetHostAndPort_ValidConfig(t *testing.T) {
config := map[string]interface{}{
"host": "example.com",
"port": 443.0,
}
configJSON, _ := json.Marshal(config)
host, port, err := extractTargetHostAndPort(configJSON)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if host != "example.com" {
t.Errorf("expected host example.com, got %s", host)
}
if port != 443 {
t.Errorf("expected port 443, got %d", port)
}
}
func TestExtractTargetHostAndPort_DefaultPort(t *testing.T) {
config := map[string]interface{}{
"hostname": "test.local",
}
configJSON, _ := json.Marshal(config)
host, port, err := extractTargetHostAndPort(configJSON)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if host != "test.local" {
t.Errorf("expected host test.local, got %s", host)
}
if port != 443 {
t.Errorf("expected default port 443, got %d", port)
}
}
func TestExtractTargetHostAndPort_MissingHost(t *testing.T) {
config := map[string]interface{}{
"port": 443.0,
}
configJSON, _ := json.Marshal(config)
_, _, err := extractTargetHostAndPort(configJSON)
if err == nil {
t.Error("expected error for missing host")
}
}
func TestExtractTargetHostAndPort_InvalidJSON(t *testing.T) {
configJSON := []byte("invalid json{")
_, _, err := extractTargetHostAndPort(configJSON)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestExtractTargetHostAndPort_AlternativeFieldNames(t *testing.T) {
tests := []struct {
name string
config map[string]interface{}
expected string
}{
{"host", map[string]interface{}{"host": "host1.com"}, "host1.com"},
{"hostname", map[string]interface{}{"hostname": "host2.com"}, "host2.com"},
{"target", map[string]interface{}{"target": "host3.com"}, "host3.com"},
{"address", map[string]interface{}{"address": "host4.com"}, "host4.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configJSON, _ := json.Marshal(tt.config)
host, _, err := extractTargetHostAndPort(configJSON)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if host != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, host)
}
})
}
}
func TestVerifyDeployment_Timeout(t *testing.T) {
cert, _ := generateTestCert()
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}))
ctx := context.Background()
result, err := verifyDeployment(ctx, "192.0.2.1", 443, certPEM, 0, 100*time.Millisecond, nil)
// Connection to reserved test IP should timeout or fail
if err == nil && result == nil {
t.Error("expected error or result for unreachable host")
}
}
func TestVerifyDeployment_InvalidCertPEM(t *testing.T) {
ctx := context.Background()
result, err := verifyDeployment(ctx, "localhost", 443, "not a cert", 0, 5*time.Second, nil)
if err == nil {
t.Error("expected error for invalid certificate PEM")
}
if result != nil {
t.Error("expected no result on error")
}
}
// Helper function to generate a test certificate for testing
func generateTestCert() (*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: "test.example.com",
},
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
DNSNames: []string{"test.example.com"},
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, err
}
return x509.ParseCertificate(certDER)
}
func TestReportVerificationResult_Success(t *testing.T) {
// Create mock HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/jobs/j-test/verify" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Method != "POST" {
t.Errorf("unexpected method: %s", r.Method)
}
// Check auth header
auth := r.Header.Get("Authorization")
if auth != "Bearer test-api-key" {
t.Errorf("unexpected auth header: %s", auth)
}
// Verify request body
var payload map[string]interface{}
json.NewDecoder(r.Body).Decode(&payload)
if payload["verified"] != true {
t.Error("expected verified to be true")
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"job_id": "j-test",
"verified": true,
})
}))
defer server.Close()
cfg := &AgentConfig{
ServerURL: server.URL,
APIKey: "test-api-key",
}
agent, _ := NewAgent(cfg, nil)
result := &VerificationResult{
ExpectedFingerprint: "abc123",
ActualFingerprint: "abc123",
Verified: true,
VerifiedAt: time.Now().UTC(),
}
err := agent.reportVerificationResult(context.Background(), "j-test", "t-nginx1", result)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
}
func TestReportVerificationResult_MissingFields(t *testing.T) {
agent, _ := NewAgent(&AgentConfig{}, nil)
result := &VerificationResult{
Verified: true,
VerifiedAt: time.Now().UTC(),
}
err := agent.reportVerificationResult(context.Background(), "", "t-nginx1", result)
if err == nil {
t.Error("expected error for missing job ID")
}
}
func TestVerifyDeployment_ContextCancellation(t *testing.T) {
cert, _ := generateTestCert()
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert.Raw,
}))
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
result, err := verifyDeployment(ctx, "localhost", 443, certPEM, 1*time.Second, 5*time.Second, nil)
if err == nil {
t.Error("expected error for cancelled context")
}
if result != nil {
t.Error("expected no result on context cancellation")
}
}
// Mock TLS server for verification testing.
// Reserved for future use when real TLS verification integration tests are added.
var _ = func(t *testing.T, cert *x509.Certificate) (string, func()) {
// Create TLS listener with test certificate
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to create listener: %v", err)
}
address := listener.Addr().String()
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
// Simple echo to keep connection alive
buf := make([]byte, 1024)
conn.Read(buf) //nolint:errcheck
}()
cleanup := func() {
listener.Close()
}
return address, cleanup
}
func TestVerificationResult_JSONMarshaling(t *testing.T) {
now := time.Now().UTC()
result := &VerificationResult{
ExpectedFingerprint: "abc123",
ActualFingerprint: "def456",
Verified: false,
VerifiedAt: now,
Error: "fingerprint mismatch",
}
data, err := json.Marshal(result)
if err != nil {
t.Errorf("unexpected error marshaling: %v", err)
}
var unmarshaled VerificationResult
err = json.Unmarshal(data, &unmarshaled)
if err != nil {
t.Errorf("unexpected error unmarshaling: %v", err)
}
if unmarshaled.Error != "fingerprint mismatch" {
t.Errorf("error mismatch: got %s", unmarshaled.Error)
}
}
func TestReportVerificationResult_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-api-key",
}
agent, _ := NewAgent(cfg, nil)
result := &VerificationResult{
ExpectedFingerprint: "abc123",
ActualFingerprint: "abc123",
Verified: true,
VerifiedAt: time.Now().UTC(),
}
err := agent.reportVerificationResult(context.Background(), "j-test", "t-nginx1", result)
if err == nil {
t.Error("expected error for server error response")
}
}
func TestExtractTargetHostAndPort_InvalidPort(t *testing.T) {
config := map[string]interface{}{
"host": "example.com",
"port": 99999.0,
}
configJSON, _ := json.Marshal(config)
_, _, err := extractTargetHostAndPort(configJSON)
if err == nil {
t.Error("expected error for invalid port")
}
}
func TestExtractTargetHostAndPort_ZeroPort(t *testing.T) {
config := map[string]interface{}{
"host": "example.com",
"port": 0.0,
}
configJSON, _ := json.Marshal(config)
_, _, err := extractTargetHostAndPort(configJSON)
if err == nil {
t.Error("expected error for zero port")
}
}
func TestVerifyDeployment_FingerprintComparison(t *testing.T) {
// Create a simple TLS server for testing
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Get the server's TLS certificate from TLS config
if len(server.TLS.Certificates) == 0 {
t.Skip("no TLS certificates configured on test server")
}
// Parse the leaf certificate from the DER bytes
leafDER := server.TLS.Certificates[0].Certificate[0]
leafCert, err := x509.ParseCertificate(leafDER)
if err != nil {
t.Fatalf("failed to parse test server certificate: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: leafCert.Raw,
}))
// Get host and port from the listener address
addr := server.Listener.Addr().String()
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("failed to parse server address: %v", err)
}
port := 0
fmt.Sscanf(portStr, "%d", &port)
// Verify deployment against the live TLS server
ctx := context.Background()
result, _ := verifyDeployment(ctx, host, port, certPEM, 0, 5*time.Second, nil)
// This test may fail in some environments due to TLS setup complexity
// The key is testing the fingerprint comparison logic
if result != nil {
if result.Verified && result.ExpectedFingerprint != result.ActualFingerprint {
t.Error("fingerprint mismatch: expected and actual should match if Verified is true")
}
}
}