package certmanager import ( "crypto/tls" "crypto/x509" "fmt" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestGenerateCA(t *testing.T) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) assert.NoError(t, err, "Failed to generate CA") assert.NotNil(t, ca, "CA certificate should not be nil") assert.NotNil(t, ca.Certificate, "CA certificate should not be nil") assert.NotEmpty(t, ca.Certificate, "CA certificate should not be empty") assert.NotNil(t, ca.PrivateKey, "CA private key should not be nil") assert.NotEmpty(t, ca.PrivateKey, "CA private key should not be empty") assert.NotNil(t, ca.X509Cert, "Parsed X509 certificate should not be nil") assert.NotNil(t, ca.PrivKey, "Parsed private key should not be nil") assert.True(t, ca.X509Cert.IsCA, "Certificate should be marked as CA") assert.Equal(t, "PMG Proxy CA", ca.X509Cert.Subject.CommonName, "Common name should be PMG Proxy CA") assert.Greater(t, ca.X509Cert.NotAfter.Sub(ca.X509Cert.NotBefore).Hours(), float64(config.CAValidityDays*24-1), "CA certificate validity period should be greater than the configured validity days") } func TestInMemoryCache(t *testing.T) { cache := NewInMemoryCache() assert.Equal(t, 0, cache.Size(), "New cache should be empty") cert := &Certificate{ Certificate: []byte("test cert"), PrivateKey: []byte("test key"), } cache.Set("example.com", cert) assert.Equal(t, 1, cache.Size(), "Cache size should be 1") retrieved, found := cache.Get("example.com") assert.True(t, found, "Certificate should be found in cache") assert.Equal(t, "test cert", string(retrieved.Certificate), "Retrieved certificate should match") _, found = cache.Get("nonexistent.com") assert.False(t, found, "Non-existent certificate should not be found") cache.Clear() assert.Equal(t, 0, cache.Size(), "Cache should be empty after Clear") } func TestNewCertificateManagerWithCA(t *testing.T) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) assert.NoError(t, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) assert.NoError(t, err, "Failed to create certificate manager") assert.NotNil(t, cm, "Certificate manager should not be nil") retrievedCA, err := cm.GetCA() assert.NoError(t, err, "Failed to get CA") assert.Equal(t, ca, retrievedCA, "Retrieved CA should match original") } func TestGenerateCertForHost(t *testing.T) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) assert.NoError(t, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) assert.NoError(t, err, "Failed to create certificate manager") hostname := "registry.npmjs.org" cert, err := cm.GenerateCertForHost(hostname) assert.NoError(t, err, "Failed to generate host certificate") assert.NotNil(t, cert, "Host certificate should not be nil") assert.NotEmpty(t, cert.Certificate, "Host certificate should not be empty") assert.NotEmpty(t, cert.PrivateKey, "Host private key should not be empty") assert.NotNil(t, cert.X509Cert, "Parsed X509 certificate should not be nil") assert.False(t, cert.X509Cert.IsCA, "Host certificate should not be marked as CA") assert.Contains(t, cert.X509Cert.DNSNames, hostname, "Certificate SAN should include hostname") assert.Equal(t, hostname, cert.X509Cert.Subject.CommonName, "Common name should match hostname") roots := x509.NewCertPool() roots.AddCert(ca.X509Cert) opts := x509.VerifyOptions{ DNSName: hostname, Roots: roots, } _, err = cert.X509Cert.Verify(opts) assert.NoError(t, err, "Certificate should be verified by CA") } func TestCertificateCaching(t *testing.T) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) assert.NoError(t, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) assert.NoError(t, err, "Failed to create certificate manager") hostname := "registry.npmjs.org" // First generation cert1, err := cm.GenerateCertForHost(hostname) assert.NoError(t, err, "Failed to generate first certificate") // Second generation should return cached certificate cert2, err := cm.GenerateCertForHost(hostname) assert.NoError(t, err, "Failed to generate second certificate") // Should be the same certificate (pointer equality) assert.Equal(t, cert1, cert2, "Second call should return cached certificate") } func TestGetTLSConfig(t *testing.T) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) assert.NoError(t, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) assert.NoError(t, err, "Failed to create certificate manager") hostname := "registry.npmjs.org" tlsConfig, err := cm.GetTLSConfig(hostname) assert.NoError(t, err, "Failed to get TLS config") assert.NotNil(t, tlsConfig, "TLS config should not be nil") assert.NotEmpty(t, tlsConfig.Certificates, "TLS config should have certificates") assert.Equal(t, uint16(tls.VersionTLS12), tlsConfig.MinVersion, "TLS min version should be TLS 1.2") } func TestCertificateExpiry(t *testing.T) { config := DefaultCertManagerConfig() config.HostCertValidityDays = 1 ca, err := GenerateCA(config) assert.NoError(t, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) assert.NoError(t, err, "Failed to create certificate manager") cert, err := cm.GenerateCertForHost("example.com") assert.NoError(t, err, "Failed to generate certificate") // Certificate should not be expired with 1 hour threshold assert.False(t, cert.IsExpired(1*time.Hour), "Certificate should not be expired yet") // Certificate should be "expired" with threshold > validity period assert.True(t, cert.IsExpired(25*time.Hour), "Certificate should be considered expired with large threshold") } func TestCertManagerConfigValidation(t *testing.T) { config := CertManagerConfig{ CAValidityDays: 0, HostCertValidityDays: 0, KeySize: 2048, } config.SetDefaults() err := config.Validate() assert.NoError(t, err, "Validate should not return error") assert.Equal(t, 365, config.CAValidityDays, "CAValidityDays should default to 365") assert.Equal(t, 1, config.HostCertValidityDays, "HostCertValidityDays should default to 1") assert.Equal(t, 2048, config.KeySize, "KeySize should default to 2048") config.KeySize = 1024 err = config.Validate() assert.Error(t, err, "Validate should return error for key size less than 2048") } func TestNewCertificateManagerWithNilCA(t *testing.T) { config := DefaultCertManagerConfig() _, err := NewCertificateManagerWithCA(nil, config) if err == nil { t.Error("Expected error when creating manager with nil CA") } } func TestConcurrentCertGeneration(t *testing.T) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) require.NoError(t, err) cm, err := NewCertificateManagerWithCA(ca, config) require.NoError(t, err) hostname := "registry.npmjs.org" const goroutines = 50 certs := make([]*Certificate, goroutines) errs := make([]error, goroutines) var wg sync.WaitGroup wg.Add(goroutines) // Launch many goroutines requesting the same hostname concurrently. // Singleflight should ensure only one RSA key generation happens. for i := 0; i < goroutines; i++ { go func(idx int) { defer wg.Done() certs[idx], errs[idx] = cm.GenerateCertForHost(hostname) }(i) } wg.Wait() for i := 0; i < goroutines; i++ { require.NoError(t, errs[i], "goroutine %d should not error", i) require.NotNil(t, certs[i], "goroutine %d should get a certificate", i) } // All goroutines should receive the same cached certificate. for i := 1; i < goroutines; i++ { assert.Equal(t, certs[0].X509Cert.SerialNumber, certs[i].X509Cert.SerialNumber, "goroutine %d should get the same certificate as goroutine 0", i) } } func BenchmarkGenerateCA(b *testing.B) { config := DefaultCertManagerConfig() b.ResetTimer() for i := 0; i < b.N; i++ { _, err := GenerateCA(config) assert.NoError(b, err, "Failed to generate CA") } } func BenchmarkGenerateHostCert(b *testing.B) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) assert.NoError(b, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) assert.NoError(b, err, "Failed to create certificate manager") b.ResetTimer() for i := 0; i < b.N; i++ { _, err := cm.GenerateCertForHost("example.com") assert.NoError(b, err, "Failed to generate host certificate") } } func BenchmarkGenerateHostCertUncached(b *testing.B) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) require.NoError(b, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) require.NoError(b, err, "Failed to create certificate manager") b.ResetTimer() for i := 0; i < b.N; i++ { hostname := fmt.Sprintf("host-%d.example.com", i) _, err := cm.GenerateCertForHost(hostname) assert.NoError(b, err, "Failed to generate host certificate") } } func BenchmarkGetTLSConfig(b *testing.B) { config := DefaultCertManagerConfig() ca, err := GenerateCA(config) require.NoError(b, err, "Failed to generate CA") cm, err := NewCertificateManagerWithCA(ca, config) require.NoError(b, err, "Failed to create certificate manager") b.Run("Uncached", func(b *testing.B) { for i := 0; i < b.N; i++ { hostname := fmt.Sprintf("host-%d.example.com", i) _, err := cm.GetTLSConfig(hostname) assert.NoError(b, err, "Failed to get TLS config") } }) b.Run("Cached", func(b *testing.B) { _, err := cm.GetTLSConfig("cached.example.com") assert.NoError(b, err) b.ResetTimer() for i := 0; i < b.N; i++ { _, err := cm.GetTLSConfig("cached.example.com") assert.NoError(b, err, "Failed to get TLS config") } }) } func BenchmarkCacheOperations(b *testing.B) { cache := NewInMemoryCache() cert := &Certificate{ Certificate: []byte("test cert"), PrivateKey: []byte("test key"), } b.Run("Set", func(b *testing.B) { for i := 0; i < b.N; i++ { cache.Set("example.com", cert) } }) b.Run("Get", func(b *testing.B) { cache.Set("example.com", cert) b.ResetTimer() for i := 0; i < b.N; i++ { cache.Get("example.com") } }) } func TestGenerateCAWithSystemCA(t *testing.T) { config := DefaultCertManagerConfig() systemBundlePath := filepath.Join(t.TempDir(), "system.pem") assert.NoError(t, os.WriteFile(systemBundlePath, []byte("SYSTEM_CA_CERT\n"), 0600)) t.Setenv("SSL_CERT_FILE", systemBundlePath) ca, err := GenerateCAWithSystemCA(config) assert.NoError(t, err, "Failed to generate CA with system CA bundle") assert.NotNil(t, ca, "CA certificate should not be nil") assert.True(t, strings.Contains(string(ca.Certificate), "SYSTEM_CA_CERT"), "Merged certificate should contain system CA content") } func TestSystemCABundleCandidatesForOS_WindowsIncludesCommonBundlePaths(t *testing.T) { t.Setenv("SSL_CERT_FILE", "") t.Setenv("CURL_CA_BUNDLE", "") t.Setenv("ProgramFiles", `C:\Program Files`) t.Setenv("ProgramFiles(x86)", `C:\Program Files (x86)`) t.Setenv("SystemRoot", `C:\Windows`) candidates := systemCABundleCandidatesForOS(goosWindows) joined := strings.Join(candidates, "|") assert.Contains(t, joined, `Git/mingw64/ssl/certs/ca-bundle.crt`) assert.Contains(t, joined, `Git/usr/ssl/certs/ca-bundle.crt`) assert.Contains(t, joined, `Git/mingw32/ssl/certs/ca-bundle.crt`) assert.Contains(t, joined, `System32/curl-ca-bundle.crt`) } func TestSystemCABundleCandidatesForOS_DarwinIncludesKnownBundlePaths(t *testing.T) { t.Setenv("SSL_CERT_FILE", "") t.Setenv("CURL_CA_BUNDLE", "") candidates := systemCABundleCandidatesForOS(goosDarwin) joined := strings.Join(candidates, "|") assert.Contains(t, joined, "/opt/homebrew/etc/openssl@3/cert.pem") assert.Contains(t, joined, "/usr/local/etc/openssl@3/cert.pem") assert.Contains(t, joined, "/etc/ssl/cert.pem") } func TestSystemCABundleCandidatesForOS_LinuxIncludesKnownBundlePaths(t *testing.T) { t.Setenv("SSL_CERT_FILE", "") t.Setenv("CURL_CA_BUNDLE", "") candidates := systemCABundleCandidatesForOS(goosLinux) joined := strings.Join(candidates, "|") assert.Contains(t, joined, "/etc/ssl/certs/ca-certificates.crt") assert.Contains(t, joined, "/etc/pki/tls/certs/ca-bundle.crt") assert.Contains(t, joined, "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem") assert.Contains(t, joined, "/etc/ssl/ca-bundle.pem") assert.Contains(t, joined, "/etc/ssl/cert.pem") } func TestFirstReadablePath(t *testing.T) { tempDir := t.TempDir() readableFile1 := filepath.Join(tempDir, "ca1.pem") readableFile2 := filepath.Join(tempDir, "ca2.pem") dirPath := filepath.Join(tempDir, "certs") missingFile := filepath.Join(tempDir, "missing.pem") assert.NoError(t, os.MkdirAll(dirPath, 0o755)) assert.NoError(t, os.WriteFile(readableFile1, []byte("CERT1"), 0o600)) assert.NoError(t, os.WriteFile(readableFile2, []byte("CERT2"), 0o600)) t.Run("returns first readable file and skips empty, directory, and missing", func(t *testing.T) { got := firstReadablePath("", dirPath, missingFile, readableFile1, readableFile2) assert.Equal(t, readableFile1, got) }) t.Run("returns empty when no readable file exists", func(t *testing.T) { got := firstReadablePath("", dirPath, missingFile) assert.Equal(t, "", got) }) } func TestGenerateCAWithSystemCA_SkipsOversizedSystemBundle(t *testing.T) { config := DefaultCertManagerConfig() systemBundlePath := filepath.Join(t.TempDir(), "oversized-system.pem") oversized := make([]byte, maxSystemCABundleBytes+1) for i := range oversized { oversized[i] = 'A' } assert.NoError(t, os.WriteFile(systemBundlePath, oversized, 0600)) t.Setenv("SSL_CERT_FILE", systemBundlePath) ca, err := GenerateCAWithSystemCA(config) assert.NoError(t, err, "oversized system CA bundle should be skipped") assert.NotNil(t, ca, "CA certificate should not be nil") assert.Less(t, len(ca.Certificate), int(maxSystemCABundleBytes), "oversized system CA content must not be merged") }