Files
Charon/backend/internal/services/certificate_service_test.go
T

448 lines
14 KiB
Go

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/CaddyProxyManagerPlus/backend/internal/models"
)
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 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 := NewCertificateService(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, 0755)
if err != nil {
t.Fatalf("Failed to create cert dir: %v", err)
}
certPath := filepath.Join(certDir, domain+".crt")
err = os.WriteFile(certPath, certPEM, 0644)
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, 0755)
assert.NoError(t, err)
expiredCertPath := filepath.Join(expiredCertDir, expiredDomain+".crt")
err = os.WriteFile(expiredCertPath, expiredCertPEM, 0644)
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{}))
cs := NewCertificateService(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{}))
cs := NewCertificateService(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, 0755)
require.NoError(t, err)
certPath := filepath.Join(certDir, domain+".crt")
err = os.WriteFile(certPath, certPEM, 0644)
require.NoError(t, err)
// 2. Call ListCertificates to trigger scan and persistence
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{}))
cs := NewCertificateService(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 := NewCertificateService(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 := NewCertificateService(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 := NewCertificateService(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, 0755)
require.NoError(t, err)
certPath := filepath.Join(certDir, domain+".crt")
err = os.WriteFile(certPath, []byte("invalid certificate content"), 0644)
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 := NewCertificateService(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, 0755)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(certDir1, domain1+".crt"), certPEM1, 0644)
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{}))
cs := NewCertificateService(tmpDir, db)
t.Run("delete non-existent certificate", func(t *testing.T) {
err := cs.DeleteCertificate(99999)
assert.Error(t, err)
assert.Equal(t, gorm.ErrRecordNotFound, 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)
})
}