package services import ( "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "fmt" "math/big" "os" "path/filepath" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/models" ) // newTestCertificateService creates a CertificateService for testing without // starting the background scan goroutine. Tests must call SyncFromDisk() explicitly. func newTestCertificateService(dataDir string, db *gorm.DB) *CertificateService { return &CertificateService{ dataDir: dataDir, db: db, scanTTL: 5 * time.Minute, } } func TestNewCertificateService(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) // Create the certificates directory certDir := filepath.Join(tmpDir, "certificates") require.NoError(t, os.MkdirAll(certDir, 0o755)) // Test service creation svc := NewCertificateService(tmpDir, db) assert.NotNil(t, svc) assert.Equal(t, tmpDir, svc.dataDir) assert.Equal(t, db, svc.db) assert.Equal(t, 5*time.Minute, svc.scanTTL) // Give the background goroutine time to complete time.Sleep(100 * time.Millisecond) } func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate private key: %v", err) } template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ CommonName: domain, }, NotBefore: time.Now(), NotAfter: expiry, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) if err != nil { t.Fatalf("Failed to create certificate: %v", err) } return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) } func TestCertificateService_GetCertificateInfo(t *testing.T) { // Create temp dir tmpDir, err := os.MkdirTemp("", "cert-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer func() { _ = os.RemoveAll(tmpDir) }() // Setup in-memory DB dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) if err != nil { t.Fatalf("Failed to connect to database: %v", err) } if err := db.AutoMigrate(&models.SSLCertificate{}); err != nil { t.Fatalf("Failed to migrate database: %v", err) } cs := newTestCertificateService(tmpDir, db) // Case 1: Valid Certificate domain := "example.com" expiry := time.Now().Add(24 * time.Hour * 60) // 60 days certPEM := generateTestCert(t, domain, expiry) // Create cert directory certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(certDir, 0o755) if err != nil { t.Fatalf("Failed to create cert dir: %v", err) } certPath := filepath.Join(certDir, domain+".crt") err = os.WriteFile(certPath, certPEM, 0o644) if err != nil { t.Fatalf("Failed to write cert file: %v", err) } // List Certificates certs, err := cs.ListCertificates() assert.NoError(t, err) assert.Len(t, certs, 1) if len(certs) > 0 { assert.Equal(t, domain, certs[0].Domain) assert.Equal(t, "valid", certs[0].Status) // Check expiry within a margin assert.WithinDuration(t, expiry, certs[0].ExpiresAt, time.Second) } // Case 2: Expired Certificate expiredDomain := "expired.com" expiredExpiry := time.Now().Add(-24 * time.Hour) // Yesterday expiredCertPEM := generateTestCert(t, expiredDomain, expiredExpiry) expiredCertDir := filepath.Join(tmpDir, "certificates", "other", expiredDomain) err = os.MkdirAll(expiredCertDir, 0o755) assert.NoError(t, err) expiredCertPath := filepath.Join(expiredCertDir, expiredDomain+".crt") err = os.WriteFile(expiredCertPath, expiredCertPEM, 0o644) assert.NoError(t, err) // Force rescan to pick up new cert err = cs.SyncFromDisk() assert.NoError(t, err) certs, err = cs.ListCertificates() assert.NoError(t, err) assert.Len(t, certs, 2) // Find the expired one var foundExpired bool for _, c := range certs { if c.Domain == expiredDomain { assert.Equal(t, "expired", c.Status) foundExpired = true } } assert.True(t, foundExpired, "Should find expired certificate") } func TestCertificateService_UploadAndDelete(t *testing.T) { // Setup tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) cs := newTestCertificateService(tmpDir, db) // Generate Cert domain := "custom.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) keyPEM := []byte("FAKE PRIVATE KEY") // Test Upload cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM)) require.NoError(t, err) assert.NotNil(t, cert) assert.Equal(t, "My Custom Cert", cert.Name) assert.Equal(t, "custom", cert.Provider) assert.Equal(t, domain, cert.Domains) // Verify it's in List certs, err := cs.ListCertificates() require.NoError(t, err) var found bool for _, c := range certs { if c.ID == cert.ID { found = true assert.Equal(t, "custom", c.Provider) break } } assert.True(t, found) // Test Delete err = cs.DeleteCertificate(cert.ID) require.NoError(t, err) // Verify it's gone certs, err = cs.ListCertificates() require.NoError(t, err) found = false for _, c := range certs { if c.ID == cert.ID { found = true break } } assert.False(t, found) } func TestCertificateService_Persistence(t *testing.T) { // Setup tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) cs := newTestCertificateService(tmpDir, db) // 1. Create a fake ACME cert file domain := "persist.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(certDir, 0o755) require.NoError(t, err) certPath := filepath.Join(certDir, domain+".crt") err = os.WriteFile(certPath, certPEM, 0o644) require.NoError(t, err) // 2. Sync from disk and call ListCertificates err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) // Verify it's in the returned list var foundInList bool for _, c := range certs { if c.Domain == domain { foundInList = true assert.Equal(t, "letsencrypt", c.Provider) break } } assert.True(t, foundInList, "Certificate should be in the returned list") // 3. Verify it's in the DB var dbCert models.SSLCertificate err = db.Where("domains = ? AND provider = ?", domain, "letsencrypt").First(&dbCert).Error assert.NoError(t, err, "Certificate should be persisted to DB") assert.Equal(t, domain, dbCert.Name) assert.Equal(t, string(certPEM), dbCert.Certificate) // 4. Delete the certificate via Service (which should delete the file) err = cs.DeleteCertificate(dbCert.ID) require.NoError(t, err) // Verify file is gone _, err = os.Stat(certPath) assert.True(t, os.IsNotExist(err), "Cert file should be deleted") // 5. Call ListCertificates again to trigger cleanup (though DB row is already gone) certs, err = cs.ListCertificates() require.NoError(t, err) // Verify it's NOT in the returned list foundInList = false for _, c := range certs { if c.Domain == domain { foundInList = true break } } assert.False(t, foundInList, "Certificate should NOT be in the returned list after deletion") // 6. Verify it's gone from the DB err = db.Where("domains = ? AND provider = ?", domain, "letsencrypt").First(&dbCert).Error assert.Error(t, err, "Certificate should be removed from DB") assert.Equal(t, gorm.ErrRecordNotFound, err) } func TestCertificateService_UploadCertificate_Errors(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) cs := newTestCertificateService(tmpDir, db) t.Run("invalid PEM format", func(t *testing.T) { cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid") assert.Error(t, err) assert.Nil(t, cert) assert.Contains(t, err.Error(), "invalid certificate PEM") }) t.Run("empty certificate", func(t *testing.T) { cert, err := cs.UploadCertificate("Empty", "", "some-key") assert.Error(t, err) assert.Nil(t, cert) }) t.Run("certificate without key allowed", func(t *testing.T) { domain := "test.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) cert, err := cs.UploadCertificate("No Key", string(certPEM), "") assert.NoError(t, err) // Uploading without key is allowed assert.NotNil(t, cert) assert.Equal(t, "", cert.PrivateKey) }) t.Run("valid certificate with name", func(t *testing.T) { domain := "valid.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) keyPEM := []byte("FAKE PRIVATE KEY") cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM)) assert.NoError(t, err) assert.NotNil(t, cert) assert.Equal(t, "Valid Cert", cert.Name) assert.Equal(t, domain, cert.Domains) assert.Equal(t, "custom", cert.Provider) }) t.Run("expired certificate can be uploaded", func(t *testing.T) { domain := "expired-upload.com" expiry := time.Now().Add(-24 * time.Hour) // Already expired certPEM := generateTestCert(t, domain, expiry) keyPEM := []byte("FAKE PRIVATE KEY") cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM)) // Should still upload successfully, but status will be expired assert.NoError(t, err) assert.NotNil(t, cert) assert.Equal(t, domain, cert.Domains) }) } func TestCertificateService_ListCertificates_EdgeCases(t *testing.T) { t.Run("empty certificates directory", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) certs, err := cs.ListCertificates() assert.NoError(t, err) assert.Len(t, certs, 0) }) t.Run("certificates directory does not exist", func(t *testing.T) { tmpDir := t.TempDir() nonExistentDir := filepath.Join(tmpDir, "does-not-exist") dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(nonExistentDir, db) certs, err := cs.ListCertificates() assert.NoError(t, err) assert.Len(t, certs, 0) }) t.Run("invalid certificate files are skipped", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Create a cert file with invalid content domain := "invalid.com" certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(certDir, 0o755) require.NoError(t, err) certPath := filepath.Join(certDir, domain+".crt") err = os.WriteFile(certPath, []byte("invalid certificate content"), 0o644) require.NoError(t, err) certs, err := cs.ListCertificates() assert.NoError(t, err) // Invalid certs should be skipped assert.Len(t, certs, 0) }) t.Run("multiple certificates from different providers", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Create LE cert domain1 := "le.example.com" expiry1 := time.Now().Add(24 * time.Hour) certPEM1 := generateTestCert(t, domain1, expiry1) certDir1 := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain1) err = os.MkdirAll(certDir1, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(certDir1, domain1+".crt"), certPEM1, 0o644) require.NoError(t, err) // Create custom cert via upload domain2 := "custom.example.com" expiry2 := time.Now().Add(48 * time.Hour) certPEM2 := generateTestCert(t, domain2, expiry2) _, err = cs.UploadCertificate("Custom", string(certPEM2), "FAKE KEY") require.NoError(t, err) certs, err := cs.ListCertificates() assert.NoError(t, err) assert.Len(t, certs, 2) // Verify both providers exist providers := make(map[string]bool) for _, c := range certs { providers[c.Provider] = true } assert.True(t, providers["letsencrypt"]) assert.True(t, providers["custom"]) }) } func TestCertificateService_DeleteCertificate_Errors(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) cs := newTestCertificateService(tmpDir, db) t.Run("delete non-existent certificate", func(t *testing.T) { // IsCertificateInUse will succeed (not in use), then First will fail err := cs.DeleteCertificate(99999) assert.Error(t, err) assert.Equal(t, gorm.ErrRecordNotFound, err) }) t.Run("delete certificate in use returns ErrCertInUse", func(t *testing.T) { // Create certificate domain := "in-use.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) cert, err := cs.UploadCertificate("In Use", string(certPEM), "FAKE KEY") require.NoError(t, err) // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "test-ph", Name: "Test Host", DomainNames: "in-use.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID, } require.NoError(t, db.Create(&ph).Error) // Attempt to delete certificate - should fail with ErrCertInUse err = cs.DeleteCertificate(cert.ID) assert.Error(t, err) assert.Equal(t, ErrCertInUse, err) // Verify certificate still exists var dbCert models.SSLCertificate err = db.First(&dbCert, "id = ?", cert.ID).Error assert.NoError(t, err) }) t.Run("delete certificate when file already removed", func(t *testing.T) { // Create and upload cert domain := "to-delete.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) cert, err := cs.UploadCertificate("To Delete", string(certPEM), "FAKE KEY") require.NoError(t, err) // Manually remove the file (custom certs stored by numeric ID) certPath := filepath.Join(tmpDir, "certificates", "custom", "cert.crt") _ = os.Remove(certPath) // Delete should still work (DB cleanup) err = cs.DeleteCertificate(cert.ID) assert.NoError(t, err) // Verify DB record is gone var dbCert models.SSLCertificate err = db.First(&dbCert, "id = ?", cert.ID).Error assert.Error(t, err) }) } func TestCertificateService_StagingCertificates(t *testing.T) { t.Run("staging certificate detected by path", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Create staging cert in acme-staging directory domain := "staging.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) // Staging path contains "acme-staging" certDir := filepath.Join(tmpDir, "certificates", "acme-staging-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(certDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(certDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) // Should be detected as staging assert.Equal(t, "letsencrypt-staging", certs[0].Provider) assert.Equal(t, "untrusted", certs[0].Status) }) t.Run("production cert preferred over staging", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) domain := "both.example.com" expiry := time.Now().Add(60 * 24 * time.Hour) // 60 days - outside expiring window certPEM := generateTestCert(t, domain, expiry) // Create staging cert first (alphabetically comes before production) stagingDir := filepath.Join(tmpDir, "certificates", "acme-staging-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(stagingDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stagingDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) // Create production cert prodDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(prodDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(prodDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) // Production should win assert.Equal(t, "letsencrypt", certs[0].Provider) assert.Equal(t, "valid", certs[0].Status) }) t.Run("upgrade from staging to production", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) domain := "upgrade.example.com" expiry := time.Now().Add(60 * 24 * time.Hour) // 60 days - outside expiring window certPEM := generateTestCert(t, domain, expiry) // First, create only staging cert stagingDir := filepath.Join(tmpDir, "certificates", "acme-staging-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(stagingDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(stagingDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) // Scan - should be staging err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) assert.Equal(t, "letsencrypt-staging", certs[0].Provider) // Now add production cert prodDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(prodDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(prodDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) // Rescan - should be upgraded to production err = cs.SyncFromDisk() require.NoError(t, err) certs, err = cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) assert.Equal(t, "letsencrypt", certs[0].Provider) assert.Equal(t, "valid", certs[0].Status) }) } func TestCertificateService_ExpiringStatus(t *testing.T) { t.Run("certificate expiring within 30 days", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Expiring in 15 days (within 30 day threshold) domain := "expiring.example.com" expiry := time.Now().Add(15 * 24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(certDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(certDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) assert.Equal(t, "expiring", certs[0].Status) }) t.Run("certificate valid for more than 30 days", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Expiring in 60 days (outside 30 day threshold) domain := "valid-long.example.com" expiry := time.Now().Add(60 * 24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(certDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(certDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) assert.Equal(t, "valid", certs[0].Status) }) t.Run("staging cert always untrusted even if expiring", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Staging cert expiring soon domain := "staging-expiring.example.com" expiry := time.Now().Add(5 * 24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) certDir := filepath.Join(tmpDir, "certificates", "acme-staging-v02.api.letsencrypt.org-directory", domain) err = os.MkdirAll(certDir, 0o755) require.NoError(t, err) err = os.WriteFile(filepath.Join(certDir, domain+".crt"), certPEM, 0o644) require.NoError(t, err) err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) // Staging takes priority over expiring for status assert.Equal(t, "untrusted", certs[0].Status) assert.Equal(t, "letsencrypt-staging", certs[0].Provider) }) } func TestCertificateService_StaleCertCleanup(t *testing.T) { t.Run("stale DB entries removed when file deleted", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) domain := "stale.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) certDir := filepath.Join(tmpDir, "certificates", "acme-v02.api.letsencrypt.org-directory", domain) certPath := filepath.Join(certDir, domain+".crt") err = os.MkdirAll(certDir, 0o755) require.NoError(t, err) err = os.WriteFile(certPath, certPEM, 0o644) require.NoError(t, err) // First scan - should create DB entry err = cs.SyncFromDisk() require.NoError(t, err) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) // Delete the file err = os.Remove(certPath) require.NoError(t, err) // Second scan - should remove stale DB entry err = cs.SyncFromDisk() require.NoError(t, err) certs, err = cs.ListCertificates() require.NoError(t, err) assert.Len(t, certs, 0) // Verify DB is clean var count int64 db.Model(&models.SSLCertificate{}).Count(&count) assert.Equal(t, int64(0), count) }) } func TestCertificateService_CertificateWithSANs(t *testing.T) { t.Run("certificate with SANs uses joined domains", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Generate cert with SANs domain := "san.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCertWithSANs(t, domain, []string{"san.example.com", "www.san.example.com", "api.san.example.com"}, expiry) keyPEM := []byte("FAKE PRIVATE KEY") cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), string(keyPEM)) require.NoError(t, err) assert.NotNil(t, cert) // Should have joined SANs assert.Contains(t, cert.Domains, "san.example.com") assert.Contains(t, cert.Domains, "www.san.example.com") assert.Contains(t, cert.Domains, "api.san.example.com") }) } func TestCertificateService_IsCertificateInUse(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) cs := newTestCertificateService(tmpDir, db) t.Run("certificate not in use", func(t *testing.T) { // Create certificate without any proxy hosts domain := "unused.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) cert, err := cs.UploadCertificate("Unused", string(certPEM), "FAKE KEY") require.NoError(t, err) inUse, err := cs.IsCertificateInUse(cert.ID) assert.NoError(t, err) assert.False(t, inUse) }) t.Run("certificate used by one proxy host", func(t *testing.T) { // Create certificate domain := "used.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) cert, err := cs.UploadCertificate("Used", string(certPEM), "FAKE KEY") require.NoError(t, err) // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "ph-1", Name: "Test Host 1", DomainNames: "used.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID, } require.NoError(t, db.Create(&ph).Error) inUse, err := cs.IsCertificateInUse(cert.ID) assert.NoError(t, err) assert.True(t, inUse) }) t.Run("certificate used by multiple proxy hosts", func(t *testing.T) { // Create certificate domain := "shared.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) cert, err := cs.UploadCertificate("Shared", string(certPEM), "FAKE KEY") require.NoError(t, err) // Create multiple proxy hosts using this certificate for i := 1; i <= 3; i++ { ph := models.ProxyHost{ UUID: fmt.Sprintf("ph-shared-%d", i), Name: fmt.Sprintf("Test Host %d", i), DomainNames: fmt.Sprintf("host%d.shared.com", i), ForwardHost: "localhost", ForwardPort: 8080 + i, CertificateID: &cert.ID, } require.NoError(t, db.Create(&ph).Error) } inUse, err := cs.IsCertificateInUse(cert.ID) assert.NoError(t, err) assert.True(t, inUse) }) t.Run("non-existent certificate", func(t *testing.T) { inUse, err := cs.IsCertificateInUse(99999) assert.NoError(t, err) // No error, just returns false assert.False(t, inUse) }) t.Run("certificate freed after proxy host deletion", func(t *testing.T) { // Create certificate domain := "freed.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) cert, err := cs.UploadCertificate("Freed", string(certPEM), "FAKE KEY") require.NoError(t, err) // Create proxy host using this certificate ph := models.ProxyHost{ UUID: "ph-freed", Name: "Test Host Freed", DomainNames: "freed.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID, } require.NoError(t, db.Create(&ph).Error) // Verify in use inUse, err := cs.IsCertificateInUse(cert.ID) assert.NoError(t, err) assert.True(t, inUse) // Delete the proxy host require.NoError(t, db.Delete(&ph).Error) // Verify no longer in use inUse, err = cs.IsCertificateInUse(cert.ID) assert.NoError(t, err) assert.False(t, inUse) // Now deletion should succeed err = cs.DeleteCertificate(cert.ID) assert.NoError(t, err) }) } func TestCertificateService_CacheBehavior(t *testing.T) { t.Run("cache returns consistent results", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Create a cert domain := "cache.example.com" expiry := time.Now().Add(24 * time.Hour) certPEM := generateTestCert(t, domain, expiry) keyPEM := []byte("FAKE PRIVATE KEY") cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM)) require.NoError(t, err) require.NotNil(t, cert) // First call populates cache certs1, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs1, 1) // Second call returns from cache certs2, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs2, 1) // Both should return the same cert assert.Equal(t, certs1[0].ID, certs2[0].ID) }) t.Run("invalidate cache forces resync", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) cs := newTestCertificateService(tmpDir, db) // Create a cert via upload (auto-invalidates) certPEM := generateTestCert(t, "invalidate.example.com", time.Now().Add(24*time.Hour)) _, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "") require.NoError(t, err) // Get list (should have 1) certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) // Manually add a cert to DB (simulating external change) dbCert := models.SSLCertificate{ Name: "External Cert", Provider: "custom", Domains: "external.example.com", Certificate: "fake-cert", } require.NoError(t, db.Create(&dbCert).Error) // Cache still returns old result certs, err = cs.ListCertificates() require.NoError(t, err) assert.Len(t, certs, 1) // Cache hasn't updated // Invalidate and resync cs.InvalidateCache() certs, err = cs.ListCertificates() require.NoError(t, err) assert.Len(t, certs, 2) // Now sees both }) t.Run("refreshCacheFromDB used when directory nonexistent", func(t *testing.T) { tmpDir := t.TempDir() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) require.NoError(t, err) require.NoError(t, db.AutoMigrate(&models.SSLCertificate{})) // Point to non-existent directory cs := newTestCertificateService(filepath.Join(tmpDir, "nonexistent"), db) // Pre-populate DB expiry := time.Now().Add(24 * time.Hour) dbCert := models.SSLCertificate{ Name: "DB Cert", Provider: "custom", Domains: "db.example.com", ExpiresAt: &expiry, Certificate: "fake-cert", } require.NoError(t, db.Create(&dbCert).Error) // Sync should succeed via DB fallback err = cs.SyncFromDisk() require.NoError(t, err) // List should return cert from DB certs, err := cs.ListCertificates() require.NoError(t, err) require.Len(t, certs, 1) assert.Equal(t, "db.example.com", certs[0].Domain) }) } // generateTestCertWithSANs generates a test certificate with Subject Alternative Names func generateTestCertWithSANs(t *testing.T, cn string, sans []string, expiry time.Time) []byte { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("Failed to generate private key: %v", err) } template := x509.Certificate{ SerialNumber: big.NewInt(1), Subject: pkix.Name{ CommonName: cn, }, DNSNames: sans, NotBefore: time.Now(), NotAfter: expiry, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) if err != nil { t.Fatalf("Failed to create certificate: %v", err) } return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) }