package main import ( "bytes" "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "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 14 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", }, }, } 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", } 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") } }