diff --git a/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go b/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go
new file mode 100644
index 00000000..49665a62
--- /dev/null
+++ b/backend/internal/api/handlers/certificate_handler_patch_coverage_test.go
@@ -0,0 +1,341 @@
+package handlers
+
+import (
+ "bytes"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// --- Delete UUID path with backup service ---
+
+func TestDelete_UUID_WithBackup_Success(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ certUUID := uuid.New().String()
+ db.Create(&models.SSLCertificate{UUID: certUUID, Name: "backup-uuid", Provider: "custom", Domains: "backup.test"})
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ mock := &mockBackupService{
+ createFunc: func() (string, error) { return "/tmp/backup.tar.gz", nil },
+ availableSpaceFunc: func() (int64, error) { return 1024 * 1024 * 1024, nil },
+ }
+ h := NewCertificateHandler(svc, mock, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.DELETE("/api/certificates/:uuid", h.Delete)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestDelete_UUID_NotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.DELETE("/api/certificates/:uuid", h.Delete)
+
+ nonExistentUUID := uuid.New().String()
+ req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+nonExistentUUID, http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusNotFound, w.Code)
+}
+
+func TestDelete_UUID_InUse(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ certUUID := uuid.New().String()
+ cert := models.SSLCertificate{UUID: certUUID, Name: "inuse-uuid", Provider: "custom", Domains: "inuse.test"}
+ db.Create(&cert)
+ db.Create(&models.ProxyHost{UUID: "ph-uuid-inuse", Name: "ph", DomainNames: "inuse.test", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID})
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.DELETE("/api/certificates/:uuid", h.Delete)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusConflict, w.Code)
+}
+
+func TestDelete_UUID_BackupLowSpace(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ certUUID := uuid.New().String()
+ db.Create(&models.SSLCertificate{UUID: certUUID, Name: "low-space", Provider: "custom", Domains: "lowspace.test"})
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ mock := &mockBackupService{
+ availableSpaceFunc: func() (int64, error) { return 1024, nil }, // 1KB - too low
+ }
+ h := NewCertificateHandler(svc, mock, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.DELETE("/api/certificates/:uuid", h.Delete)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusInsufficientStorage, w.Code)
+}
+
+func TestDelete_UUID_BackupSpaceCheckError(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ certUUID := uuid.New().String()
+ db.Create(&models.SSLCertificate{UUID: certUUID, Name: "space-err", Provider: "custom", Domains: "spaceerr.test"})
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ mock := &mockBackupService{
+ availableSpaceFunc: func() (int64, error) { return 0, fmt.Errorf("disk error") },
+ createFunc: func() (string, error) { return "/tmp/backup.tar.gz", nil },
+ }
+ h := NewCertificateHandler(svc, mock, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.DELETE("/api/certificates/:uuid", h.Delete)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ // Space check error → proceeds with backup → succeeds
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestDelete_UUID_BackupCreateError(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ certUUID := uuid.New().String()
+ db.Create(&models.SSLCertificate{UUID: certUUID, Name: "backup-fail", Provider: "custom", Domains: "backupfail.test"})
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ mock := &mockBackupService{
+ availableSpaceFunc: func() (int64, error) { return 1024 * 1024 * 1024, nil },
+ createFunc: func() (string, error) { return "", fmt.Errorf("backup creation failed") },
+ }
+ h := NewCertificateHandler(svc, mock, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.DELETE("/api/certificates/:uuid", h.Delete)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+}
+
+// --- Delete UUID with notification service ---
+
+func TestDelete_UUID_WithNotification(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.Setting{}, &models.Notification{}, &models.NotificationProvider{}))
+
+ certUUID := uuid.New().String()
+ db.Create(&models.SSLCertificate{UUID: certUUID, Name: "notify-cert", Provider: "custom", Domains: "notify.test"})
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ notifSvc := services.NewNotificationService(db, nil)
+ h := NewCertificateHandler(svc, nil, notifSvc)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.DELETE("/api/certificates/:uuid", h.Delete)
+
+ req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+certUUID, http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+// --- Validate handler ---
+
+func TestValidate_Success(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ certPEM, _, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("certificate_file", "cert.pem")
+ require.NoError(t, err)
+ _, err = part.Write([]byte(certPEM))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates/validate", h.Validate)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+func TestValidate_InvalidCert(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+ part, err := writer.CreateFormFile("certificate_file", "cert.pem")
+ require.NoError(t, err)
+ _, err = part.Write([]byte("not a certificate"))
+ require.NoError(t, err)
+ require.NoError(t, writer.Close())
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates/validate", h.Validate)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+ assert.Contains(t, w.Body.String(), "unrecognized certificate format")
+}
+
+func TestValidate_NoCertFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates/validate", h.Validate)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", http.NoBody)
+ req.Header.Set("Content-Type", "multipart/form-data; boundary=boundary")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+func TestValidate_WithKeyAndChain(t *testing.T) {
+ tmpDir := t.TempDir()
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ certPEM, keyPEM, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ body := &bytes.Buffer{}
+ writer := multipart.NewWriter(body)
+
+ certPart, err := writer.CreateFormFile("certificate_file", "cert.pem")
+ require.NoError(t, err)
+ _, err = certPart.Write([]byte(certPEM))
+ require.NoError(t, err)
+
+ keyPart, err := writer.CreateFormFile("key_file", "key.pem")
+ require.NoError(t, err)
+ _, err = keyPart.Write([]byte(keyPEM))
+ require.NoError(t, err)
+
+ chainPart, err := writer.CreateFormFile("chain_file", "chain.pem")
+ require.NoError(t, err)
+ _, err = chainPart.Write([]byte(certPEM)) // self-signed chain
+ require.NoError(t, err)
+
+ require.NoError(t, writer.Close())
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates/validate", h.Validate)
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ assert.Equal(t, http.StatusOK, w.Code)
+}
+
+// --- Get handler DB error (non-NotFound) ---
+
+func TestGet_DBError(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ // Deliberately don't migrate - any query will fail with "no such table"
+
+ svc := services.NewCertificateService(t.TempDir(), db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.GET("/api/certificates/:uuid", h.Get)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/certificates/"+uuid.New().String(), http.NoBody)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ // Should be 500 since the table doesn't exist
+ assert.Equal(t, http.StatusInternalServerError, w.Code)
+}
diff --git a/backend/internal/api/handlers/certificate_handler_upload_export_test.go b/backend/internal/api/handlers/certificate_handler_upload_export_test.go
new file mode 100644
index 00000000..a91f02b2
--- /dev/null
+++ b/backend/internal/api/handlers/certificate_handler_upload_export_test.go
@@ -0,0 +1,382 @@
+package handlers
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/driver/sqlite"
+ "gorm.io/gorm"
+
+ "github.com/Wikid82/charon/backend/internal/crypto"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/Wikid82/charon/backend/internal/services"
+)
+
+// --- Upload: with chain file (covers chain_file multipart branch) ---
+
+func TestCertificateHandler_Upload_WithChainFile(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ tmpDir := t.TempDir()
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates", h.Upload)
+
+ certPEM, keyPEM, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ _ = writer.WriteField("name", "chain-cert")
+ part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
+ _, _ = part.Write([]byte(certPEM))
+ part2, _ := writer.CreateFormFile("key_file", "key.pem")
+ _, _ = part2.Write([]byte(keyPEM))
+ part3, _ := writer.CreateFormFile("chain_file", "chain.pem")
+ _, _ = part3.Write([]byte(certPEM))
+ _ = writer.Close()
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusCreated, w.Code, "body: %s", w.Body.String())
+}
+
+// --- Upload: invalid cert data ---
+
+func TestCertificateHandler_Upload_InvalidCertData(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ tmpDir := t.TempDir()
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates", h.Upload)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ _ = writer.WriteField("name", "bad-cert")
+ part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
+ _, _ = part.Write([]byte("not-a-cert"))
+ part2, _ := writer.CreateFormFile("key_file", "key.pem")
+ _, _ = part2.Write([]byte("not-a-key"))
+ _ = writer.Close()
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+}
+
+// --- Export re-authentication flow ---
+
+func setupExportRouter(t *testing.T, db *gorm.DB) (*gin.Engine, *CertificateHandler) {
+ t.Helper()
+ tmpDir := t.TempDir()
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+ h.SetDB(db)
+
+ r := gin.New()
+ return r, h
+}
+
+func newTestEncSvc(t *testing.T) *crypto.EncryptionService {
+ t.Helper()
+ key := make([]byte, 32)
+ for i := range key {
+ key[i] = byte(i)
+ }
+ svc, err := crypto.NewEncryptionService(base64.StdEncoding.EncodeToString(key))
+ require.NoError(t, err)
+ return svc
+}
+
+func TestCertificateHandler_Export_IncludeKeySuccess(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
+
+ user := models.User{UUID: "export-user-1", Email: "export@test.com", Name: "Exporter"}
+ require.NoError(t, user.SetPassword("correctpassword"))
+ require.NoError(t, db.Create(&user).Error)
+
+ encSvc := newTestEncSvc(t)
+ tmpDir := t.TempDir()
+ svc := services.NewCertificateService(tmpDir, db, encSvc)
+ h := NewCertificateHandler(svc, nil, nil)
+ h.SetDB(db)
+
+ certPEM, keyPEM, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+ info, err := svc.UploadCertificate("export-cert", certPEM, keyPEM, "")
+ require.NoError(t, err)
+
+ r := gin.New()
+ r.Use(func(c *gin.Context) {
+ c.Set("user", map[string]any{"id": user.ID})
+ c.Next()
+ })
+ r.POST("/api/certificates/:uuid/export", h.Export)
+
+ payload, _ := json.Marshal(map[string]any{
+ "format": "pem",
+ "include_key": true,
+ "password": "correctpassword",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/"+info.UUID+"/export", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
+ assert.Contains(t, w.Header().Get("Content-Disposition"), "export-cert.pem")
+}
+
+func TestCertificateHandler_Export_IncludeKeyWrongPassword(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
+
+ r, h := setupExportRouter(t, db)
+
+ user := models.User{UUID: "wrong-pw-user", Email: "wrong@test.com", Name: "Wrong"}
+ require.NoError(t, user.SetPassword("rightpass"))
+ require.NoError(t, db.Create(&user).Error)
+
+ r.Use(func(c *gin.Context) {
+ c.Set("user", map[string]any{"id": user.ID})
+ c.Next()
+ })
+ r.POST("/api/certificates/:uuid/export", h.Export)
+
+ payload, _ := json.Marshal(map[string]any{
+ "format": "pem",
+ "include_key": true,
+ "password": "wrongpass",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "incorrect password")
+}
+
+func TestCertificateHandler_Export_NoUserInContext(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
+
+ r, h := setupExportRouter(t, db)
+ r.POST("/api/certificates/:uuid/export", h.Export)
+
+ payload, _ := json.Marshal(map[string]any{
+ "format": "pem",
+ "include_key": true,
+ "password": "anything",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "authentication required")
+}
+
+func TestCertificateHandler_Export_InvalidSession(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
+
+ r, h := setupExportRouter(t, db)
+ r.Use(func(c *gin.Context) {
+ c.Set("user", "not-a-map")
+ c.Next()
+ })
+ r.POST("/api/certificates/:uuid/export", h.Export)
+
+ payload, _ := json.Marshal(map[string]any{
+ "format": "pem",
+ "include_key": true,
+ "password": "anything",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "invalid session")
+}
+
+func TestCertificateHandler_Export_MissingUserID(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
+
+ r, h := setupExportRouter(t, db)
+ r.Use(func(c *gin.Context) {
+ c.Set("user", map[string]any{"name": "test"})
+ c.Next()
+ })
+ r.POST("/api/certificates/:uuid/export", h.Export)
+
+ payload, _ := json.Marshal(map[string]any{
+ "format": "pem",
+ "include_key": true,
+ "password": "anything",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "invalid session")
+}
+
+func TestCertificateHandler_Export_UserNotFound(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}, &models.User{}))
+
+ r, h := setupExportRouter(t, db)
+ r.Use(func(c *gin.Context) {
+ c.Set("user", map[string]any{"id": uint(9999)})
+ c.Next()
+ })
+ r.POST("/api/certificates/:uuid/export", h.Export)
+
+ payload, _ := json.Marshal(map[string]any{
+ "format": "pem",
+ "include_key": true,
+ "password": "anything",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/fake-uuid/export", bytes.NewReader(payload))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusForbidden, w.Code)
+ assert.Contains(t, w.Body.String(), "user not found")
+}
+
+// --- Validate handler with key and chain ---
+
+func TestCertificateHandler_Validate_WithKeyAndChain(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ tmpDir := t.TempDir()
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates/validate", h.Validate)
+
+ certPEM, keyPEM, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
+ _, _ = part.Write([]byte(certPEM))
+ part2, _ := writer.CreateFormFile("key_file", "key.pem")
+ _, _ = part2.Write([]byte(keyPEM))
+ part3, _ := writer.CreateFormFile("chain_file", "chain.pem")
+ _, _ = part3.Write([]byte(certPEM))
+ _ = writer.Close()
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String())
+}
+
+func TestCertificateHandler_Validate_InvalidCert(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ tmpDir := t.TempDir()
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates/validate", h.Validate)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
+ _, _ = part.Write([]byte("not-a-cert"))
+ _ = writer.Close()
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+ var resp map[string]any
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+ errList, ok := resp["errors"].([]any)
+ assert.True(t, ok)
+ assert.Greater(t, len(errList), 0, "expected validation errors in response")
+}
+
+func TestCertificateHandler_Validate_MissingCertFile(t *testing.T) {
+ db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}))
+
+ tmpDir := t.TempDir()
+ svc := services.NewCertificateService(tmpDir, db, nil)
+ h := NewCertificateHandler(svc, nil, nil)
+
+ r := gin.New()
+ r.Use(mockAuthMiddleware())
+ r.POST("/api/certificates/validate", h.Validate)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ _ = writer.WriteField("name", "test")
+ _ = writer.Close()
+
+ req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusBadRequest, w.Code)
+ assert.Contains(t, w.Body.String(), "certificate_file is required")
+}
diff --git a/backend/internal/caddy/config_customcert_test.go b/backend/internal/caddy/config_customcert_test.go
new file mode 100644
index 00000000..3d40d7eb
--- /dev/null
+++ b/backend/internal/caddy/config_customcert_test.go
@@ -0,0 +1,166 @@
+package caddy
+
+import (
+ "encoding/base64"
+ "testing"
+
+ "github.com/Wikid82/charon/backend/internal/crypto"
+ "github.com/Wikid82/charon/backend/internal/models"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func newTestEncSvc(t *testing.T) *crypto.EncryptionService {
+ t.Helper()
+ key := make([]byte, 32)
+ for i := range key {
+ key[i] = byte(i)
+ }
+ svc, err := crypto.NewEncryptionService(base64.StdEncoding.EncodeToString(key))
+ require.NoError(t, err)
+ return svc
+}
+
+// Test: encrypted key with encryption service → decrypt success → cert loaded
+func TestGenerateConfig_CustomCert_EncryptedKey(t *testing.T) {
+ encSvc := newTestEncSvc(t)
+ encKey, err := encSvc.Encrypt([]byte("-----BEGIN PRIVATE KEY-----\nfake-key-data\n-----END PRIVATE KEY-----"))
+ require.NoError(t, err)
+
+ certID := uint(10)
+ hosts := []models.ProxyHost{
+ {
+ UUID: "h-enc", DomainNames: "enc.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
+ CertificateID: &certID,
+ Certificate: &models.SSLCertificate{
+ ID: certID, UUID: "c-enc", Name: "EncCert", Provider: "custom",
+ Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
+ PrivateKeyEncrypted: encKey,
+ },
+ },
+ }
+
+ cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil, encSvc)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ require.NotNil(t, cfg.Apps.TLS)
+ require.NotNil(t, cfg.Apps.TLS.Certificates)
+ assert.NotEmpty(t, cfg.Apps.TLS.Certificates.LoadPEM)
+}
+
+// Test: encrypted key with no encryption service → skip
+func TestGenerateConfig_CustomCert_EncryptedKeyNoEncSvc(t *testing.T) {
+ certID := uint(11)
+ hosts := []models.ProxyHost{
+ {
+ UUID: "h-noenc", DomainNames: "noenc.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
+ CertificateID: &certID,
+ Certificate: &models.SSLCertificate{
+ ID: certID, UUID: "c-noenc", Name: "NoEncSvcCert", Provider: "custom",
+ Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
+ PrivateKeyEncrypted: "encrypted-data-here",
+ },
+ },
+ }
+
+ cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ // Cert should be skipped - no TLS certs loaded
+ if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
+ assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
+ }
+}
+
+// Test: no key at all → skip
+func TestGenerateConfig_CustomCert_NoKey(t *testing.T) {
+ certID := uint(12)
+ hosts := []models.ProxyHost{
+ {
+ UUID: "h-nokey", DomainNames: "nokey.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
+ CertificateID: &certID,
+ Certificate: &models.SSLCertificate{
+ ID: certID, UUID: "c-nokey", Name: "NoKeyCert", Provider: "custom",
+ Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
+ },
+ },
+ }
+
+ cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
+ assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
+ }
+}
+
+// Test: missing cert PEM → skip
+func TestGenerateConfig_CustomCert_NoCertPEM(t *testing.T) {
+ certID := uint(13)
+ hosts := []models.ProxyHost{
+ {
+ UUID: "h-nocert", DomainNames: "nocert.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
+ CertificateID: &certID,
+ Certificate: &models.SSLCertificate{
+ ID: certID, UUID: "c-nocert", Name: "NoCertPEM", Provider: "custom",
+ PrivateKey: "some-key",
+ },
+ },
+ }
+
+ cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
+ assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
+ }
+}
+
+// Test: cert with chain → chain concatenated
+func TestGenerateConfig_CustomCert_WithChain(t *testing.T) {
+ certID := uint(14)
+ hosts := []models.ProxyHost{
+ {
+ UUID: "h-chain", DomainNames: "chain.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
+ CertificateID: &certID,
+ Certificate: &models.SSLCertificate{
+ ID: certID, UUID: "c-chain", Name: "ChainCert", Provider: "custom",
+ Certificate: "-----BEGIN CERTIFICATE-----\nleaf-cert\n-----END CERTIFICATE-----",
+ PrivateKey: "-----BEGIN PRIVATE KEY-----\nkey-data\n-----END PRIVATE KEY-----",
+ CertificateChain: "-----BEGIN CERTIFICATE-----\nca-cert\n-----END CERTIFICATE-----",
+ },
+ },
+ }
+
+ cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ require.NotNil(t, cfg.Apps.TLS)
+ require.NotNil(t, cfg.Apps.TLS.Certificates)
+ require.NotEmpty(t, cfg.Apps.TLS.Certificates.LoadPEM)
+ assert.Contains(t, cfg.Apps.TLS.Certificates.LoadPEM[0].Certificate, "ca-cert")
+}
+
+// Test: decrypt failure → skip
+func TestGenerateConfig_CustomCert_DecryptFailure(t *testing.T) {
+ encSvc := newTestEncSvc(t)
+ certID := uint(15)
+ hosts := []models.ProxyHost{
+ {
+ UUID: "h-decfail", DomainNames: "decfail.test", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true,
+ CertificateID: &certID,
+ Certificate: &models.SSLCertificate{
+ ID: certID, UUID: "c-decfail", Name: "DecryptFail", Provider: "custom",
+ Certificate: "-----BEGIN CERTIFICATE-----\nfake-cert\n-----END CERTIFICATE-----",
+ PrivateKeyEncrypted: "not-valid-encrypted-data",
+ },
+ },
+ }
+
+ cfg, err := GenerateConfig(hosts, "/data", "admin@test.com", "/dist", "letsencrypt", true, false, false, false, false, "", nil, nil, nil, nil, nil, encSvc)
+ require.NoError(t, err)
+ require.NotNil(t, cfg)
+ if cfg.Apps.TLS != nil && cfg.Apps.TLS.Certificates != nil {
+ assert.Empty(t, cfg.Apps.TLS.Certificates.LoadPEM)
+ }
+}
diff --git a/backend/internal/services/certificate_helpers_test.go b/backend/internal/services/certificate_helpers_test.go
new file mode 100644
index 00000000..330ddc59
--- /dev/null
+++ b/backend/internal/services/certificate_helpers_test.go
@@ -0,0 +1,38 @@
+package services
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "time"
+)
+
+func generateSelfSignedCertPEM() (string, string, error) {
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ if err != nil {
+ return "", "", err
+ }
+
+ template := x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "test.example.com"},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(365 * 24 * time.Hour),
+ 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 {
+ return "", "", err
+ }
+
+ certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
+ return string(certPEM), string(keyPEM), nil
+}
+
diff --git a/backend/internal/services/certificate_service_extra_coverage_test.go b/backend/internal/services/certificate_service_extra_coverage_test.go
new file mode 100644
index 00000000..682573b1
--- /dev/null
+++ b/backend/internal/services/certificate_service_extra_coverage_test.go
@@ -0,0 +1,292 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "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"
+)
+
+// --- buildChainEntries ---
+
+func TestBuildChainEntries(t *testing.T) {
+ certPEM := string(generateTestCert(t, "leaf.example.com", time.Now().Add(24*time.Hour)))
+ chainPEM := string(generateTestCert(t, "ca.example.com", time.Now().Add(365*24*time.Hour)))
+
+ t.Run("leaf only", func(t *testing.T) {
+ entries := buildChainEntries(certPEM, "")
+ require.Len(t, entries, 1)
+ assert.Equal(t, "leaf.example.com", entries[0].Subject)
+ })
+
+ t.Run("leaf and chain", func(t *testing.T) {
+ entries := buildChainEntries(certPEM, chainPEM)
+ require.Len(t, entries, 2)
+ assert.Equal(t, "leaf.example.com", entries[0].Subject)
+ assert.Equal(t, "ca.example.com", entries[1].Subject)
+ })
+
+ t.Run("empty cert", func(t *testing.T) {
+ entries := buildChainEntries("", chainPEM)
+ require.Len(t, entries, 1)
+ assert.Equal(t, "ca.example.com", entries[0].Subject)
+ })
+
+ t.Run("both empty", func(t *testing.T) {
+ entries := buildChainEntries("", "")
+ assert.Empty(t, entries)
+ })
+
+ t.Run("invalid PEM ignored", func(t *testing.T) {
+ entries := buildChainEntries("not-pem", "also-not-pem")
+ assert.Empty(t, entries)
+ })
+}
+
+// --- certStatus ---
+
+func TestCertStatus(t *testing.T) {
+ now := time.Now()
+
+ t.Run("valid", func(t *testing.T) {
+ expiry := now.Add(60 * 24 * time.Hour)
+ cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"}
+ assert.Equal(t, "valid", certStatus(cert))
+ })
+
+ t.Run("expired", func(t *testing.T) {
+ expiry := now.Add(-time.Hour)
+ cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"}
+ assert.Equal(t, "expired", certStatus(cert))
+ })
+
+ t.Run("expiring soon", func(t *testing.T) {
+ expiry := now.Add(15 * 24 * time.Hour) // within 30d window
+ cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "custom"}
+ assert.Equal(t, "expiring", certStatus(cert))
+ })
+
+ t.Run("staging provider", func(t *testing.T) {
+ expiry := now.Add(60 * 24 * time.Hour)
+ cert := models.SSLCertificate{ExpiresAt: &expiry, Provider: "letsencrypt-staging"}
+ assert.Equal(t, "untrusted", certStatus(cert))
+ })
+
+ t.Run("nil expiry", func(t *testing.T) {
+ cert := models.SSLCertificate{Provider: "custom"}
+ assert.Equal(t, "valid", certStatus(cert))
+ })
+}
+
+// --- ListCertificates cache paths ---
+
+func TestListCertificates_InitializedAndStale(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)
+
+ // First call initializes
+ certs1, err := cs.ListCertificates()
+ require.NoError(t, err)
+ assert.Empty(t, certs1)
+
+ // Force stale but initialized
+ cs.cacheMu.Lock()
+ cs.initialized = true
+ cs.lastScan = time.Time{} // zero → stale
+ cs.cacheMu.Unlock()
+
+ // Should still return (stale) cache and trigger background sync
+ certs2, err := cs.ListCertificates()
+ require.NoError(t, err)
+ assert.NotNil(t, certs2)
+}
+
+func TestListCertificates_CacheFresh(t *testing.T) {
+ tmpDir := t.TempDir()
+ dsn := fmt.Sprintf("file:%s_fresh?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)
+ cs.cacheMu.Lock()
+ cs.initialized = true
+ cs.lastScan = time.Now()
+ cs.cache = []CertificateInfo{{Name: "cached"}}
+ cs.scanTTL = 5 * time.Minute
+ cs.cacheMu.Unlock()
+
+ certs, err := cs.ListCertificates()
+ require.NoError(t, err)
+ require.Len(t, certs, 1)
+ assert.Equal(t, "cached", certs[0].Name)
+}
+
+// --- ValidateCertificate extra branches ---
+
+func TestValidateCertificate_KeyMismatch(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)
+
+ // Generate two separate cert/key pairs so key doesn't match cert
+ certPEM, _ := generateTestCertAndKey(t, "mismatch.example.com", time.Now().Add(24*time.Hour))
+ _, keyPEM := generateTestCertAndKey(t, "other.example.com", time.Now().Add(24*time.Hour))
+
+ result, err := cs.ValidateCertificate(string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+ // Key mismatch goes to Errors
+ found := false
+ for _, e := range result.Errors {
+ if strings.Contains(e, "mismatch") {
+ found = true
+ }
+ }
+ assert.True(t, found, "expected key mismatch error, got errors: %v, warnings: %v", result.Errors, result.Warnings)
+}
+
+// --- UploadCertificate with encryption ---
+
+func TestUploadCertificate_WithEncryption(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 := newTestCertServiceWithEnc(t, tmpDir, db)
+
+ certPEM, keyPEM := generateTestCertAndKey(t, "enc.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("encrypted-cert", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+ assert.Equal(t, "encrypted-cert", info.Name)
+
+ // Verify private key was encrypted in DB
+ var stored models.SSLCertificate
+ require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error)
+ assert.NotEmpty(t, stored.PrivateKeyEncrypted)
+ assert.Empty(t, stored.PrivateKey) // should not store plaintext
+}
+
+// --- checkExpiry additional branches ---
+
+func TestCheckExpiry_NoNotificationService(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{}, &models.Setting{}, &models.NotificationProvider{}))
+
+ cs := &CertificateService{
+ dataDir: tmpDir,
+ db: db,
+ scanTTL: 5 * time.Minute,
+ }
+ // No notification service set — should not panic
+ cs.checkExpiry(context.Background(), nil, 30)
+}
+
+// --- DeleteCertificate with backup service ---
+
+func TestDeleteCertificate_Success(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)
+ certPEM, keyPEM := generateTestCertAndKey(t, "delete.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("to-delete", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ err = cs.DeleteCertificate(info.UUID)
+ assert.NoError(t, err)
+
+ // Verify deleted
+ _, err = cs.GetCertificate(info.UUID)
+ assert.ErrorIs(t, err, ErrCertNotFound)
+}
+
+func TestDeleteCertificate_InUse(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)
+ certPEM, keyPEM := generateTestCertAndKey(t, "inuse.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("in-use-cert", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ // Find the cert and assign to a host
+ var stored models.SSLCertificate
+ require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error)
+ ph := models.ProxyHost{
+ UUID: "ph-inuse",
+ Name: "InUse Host",
+ DomainNames: "inuse.example.com",
+ ForwardHost: "localhost",
+ ForwardPort: 8080,
+ CertificateID: &stored.ID,
+ }
+ require.NoError(t, db.Create(&ph).Error)
+
+ err = cs.DeleteCertificate(info.UUID)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "in use")
+}
+
+// --- IsCertificateInUse ---
+
+func TestIsCertificateInUse(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)
+
+ cert := models.SSLCertificate{
+ UUID: "inuse-test", Name: "In Use Test", Provider: "custom",
+ Domains: "test.example.com", CommonName: "test.example.com",
+ }
+ require.NoError(t, db.Create(&cert).Error)
+
+ t.Run("not in use", func(t *testing.T) {
+ inUse, err := cs.IsCertificateInUse(cert.ID)
+ require.NoError(t, err)
+ assert.False(t, inUse)
+ })
+
+ t.Run("in use", func(t *testing.T) {
+ ph := models.ProxyHost{
+ UUID: "ph-check", Name: "Check Host", DomainNames: "test.example.com",
+ ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID,
+ }
+ require.NoError(t, db.Create(&ph).Error)
+
+ inUse, err := cs.IsCertificateInUse(cert.ID)
+ require.NoError(t, err)
+ assert.True(t, inUse)
+ })
+}
diff --git a/backend/internal/services/certificate_service_patch_coverage_test.go b/backend/internal/services/certificate_service_patch_coverage_test.go
new file mode 100644
index 00000000..b063bc81
--- /dev/null
+++ b/backend/internal/services/certificate_service_patch_coverage_test.go
@@ -0,0 +1,596 @@
+package services
+
+import (
+ "context"
+ "fmt"
+ "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"
+)
+
+// --- ExportCertificate DER format ---
+
+func TestExportCertificate_DER(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)
+ certPEM, keyPEM := generateTestCertAndKey(t, "der-export.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("der-export", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ data, filename, err := cs.ExportCertificate(info.UUID, "der", false, "")
+ require.NoError(t, err)
+ assert.NotEmpty(t, data)
+ assert.Contains(t, filename, ".der")
+}
+
+// --- ExportCertificate PFX format ---
+
+func TestExportCertificate_PFX(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 := newTestCertServiceWithEnc(t, tmpDir, db)
+ certPEM, keyPEM := generateTestCertAndKey(t, "pfx-export.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("pfx-export", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ data, filename, err := cs.ExportCertificate(info.UUID, "pfx", true, "test-password")
+ require.NoError(t, err)
+ assert.NotEmpty(t, data)
+ assert.Contains(t, filename, ".pfx")
+}
+
+func TestExportCertificate_P12(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 := newTestCertServiceWithEnc(t, tmpDir, db)
+ certPEM, keyPEM := generateTestCertAndKey(t, "p12-export.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("p12-export", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ data, filename, err := cs.ExportCertificate(info.UUID, "p12", true, "password")
+ require.NoError(t, err)
+ assert.NotEmpty(t, data)
+ assert.Contains(t, filename, ".pfx")
+}
+
+func TestExportCertificate_UnsupportedFormat(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)
+ certPEM, keyPEM := generateTestCertAndKey(t, "unsupported.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("unsupported-fmt", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ _, _, err = cs.ExportCertificate(info.UUID, "xml", false, "")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported export format")
+}
+
+func TestExportCertificate_PEMWithKey(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 := newTestCertServiceWithEnc(t, tmpDir, db)
+ certPEM, keyPEM := generateTestCertAndKey(t, "pem-key.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("pem-key-export", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ data, filename, err := cs.ExportCertificate(info.UUID, "pem", true, "")
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "PRIVATE KEY")
+ assert.Contains(t, filename, ".pem")
+}
+
+func TestExportCertificate_NotFound(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)
+ _, _, err = cs.ExportCertificate("nonexistent-uuid", "pem", false, "")
+ assert.ErrorIs(t, err, ErrCertNotFound)
+}
+
+// --- GetDecryptedPrivateKey ---
+
+func TestGetDecryptedPrivateKey_NoEncryptedKey(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)
+ cert := &models.SSLCertificate{PrivateKeyEncrypted: ""}
+ _, err = cs.GetDecryptedPrivateKey(cert)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no encrypted private key")
+}
+
+func TestGetDecryptedPrivateKey_NoEncryptionService(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) // no encSvc
+ cert := &models.SSLCertificate{PrivateKeyEncrypted: "some-encrypted-data"}
+ _, err = cs.GetDecryptedPrivateKey(cert)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "encryption service not configured")
+}
+
+// --- MigratePrivateKeys ---
+
+func TestMigratePrivateKeys_NoEncryptionService(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)
+ err = cs.MigratePrivateKeys()
+ assert.NoError(t, err) // should return nil without error
+}
+
+func TestMigratePrivateKeys_NoCertsToMigrate(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{}))
+ // MigratePrivateKeys uses raw SQL against private_key column (gorm:"-"), so add it manually
+ db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''")
+
+ cs := newTestCertServiceWithEnc(t, tmpDir, db)
+ err = cs.MigratePrivateKeys()
+ assert.NoError(t, err)
+}
+
+func TestMigratePrivateKeys_WithPlaintextKey(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{}))
+ // MigratePrivateKeys uses raw SQL against private_key column (gorm:"-"), so add it manually
+ db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''")
+ cs := newTestCertServiceWithEnc(t, tmpDir, db)
+ _, keyPEM := generateTestCertAndKey(t, "migrate.example.com", time.Now().Add(24*time.Hour))
+
+ // Insert a cert with plaintext private_key via raw SQL
+ db.Exec("INSERT INTO ssl_certificates (uuid, name, provider, domains, common_name, private_key) VALUES (?, ?, ?, ?, ?, ?)",
+ "migrate-uuid", "Migrate Test", "custom", "migrate.example.com", "migrate.example.com", string(keyPEM))
+
+ err = cs.MigratePrivateKeys()
+ assert.NoError(t, err)
+
+ // Verify the key was encrypted
+ var encKey string
+ db.Raw("SELECT private_key_enc FROM ssl_certificates WHERE uuid = ?", "migrate-uuid").Scan(&encKey)
+ assert.NotEmpty(t, encKey)
+
+ // Verify plaintext key was cleared
+ var plainKey string
+ db.Raw("SELECT private_key FROM ssl_certificates WHERE uuid = ?", "migrate-uuid").Scan(&plainKey)
+ assert.Empty(t, plainKey)
+}
+
+// --- DeleteCertificateByID ---
+
+func TestDeleteCertificateByID_Success(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)
+ certPEM, keyPEM := generateTestCertAndKey(t, "byid.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("by-id-delete", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ var stored models.SSLCertificate
+ require.NoError(t, db.Where("uuid = ?", info.UUID).First(&stored).Error)
+
+ err = cs.DeleteCertificateByID(stored.ID)
+ assert.NoError(t, err)
+}
+
+func TestDeleteCertificateByID_NotFound(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)
+ err = cs.DeleteCertificateByID(99999)
+ assert.Error(t, err)
+}
+
+// --- UpdateCertificate ---
+
+func TestUpdateCertificate_Success(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)
+ certPEM, keyPEM := generateTestCertAndKey(t, "update.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("old-name", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ updated, err := cs.UpdateCertificate(info.UUID, "new-name")
+ require.NoError(t, err)
+ assert.Equal(t, "new-name", updated.Name)
+}
+
+func TestUpdateCertificate_NotFound(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)
+ _, err = cs.UpdateCertificate("nonexistent", "name")
+ assert.ErrorIs(t, err, ErrCertNotFound)
+}
+
+// --- IsCertificateInUseByUUID ---
+
+func TestIsCertificateInUseByUUID_NotFound(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)
+ _, err = cs.IsCertificateInUseByUUID("nonexistent-uuid")
+ assert.ErrorIs(t, err, ErrCertNotFound)
+}
+
+func TestIsCertificateInUseByUUID_NotInUse(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)
+ certPEM, keyPEM := generateTestCertAndKey(t, "inuse-uuid.example.com", time.Now().Add(24*time.Hour))
+ info, err := cs.UploadCertificate("uuid-inuse-test", string(certPEM), string(keyPEM), "")
+ require.NoError(t, err)
+
+ inUse, err := cs.IsCertificateInUseByUUID(info.UUID)
+ require.NoError(t, err)
+ assert.False(t, inUse)
+}
+
+// --- CheckExpiringCertificates ---
+
+func TestCheckExpiringCertificates_WithExpiring(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)
+
+ // Create a cert expiring in 10 days
+ expiry := time.Now().Add(10 * 24 * time.Hour)
+ notBefore := time.Now().Add(-24 * time.Hour)
+ db.Create(&models.SSLCertificate{
+ UUID: "expiring-uuid", Name: "Expiring Cert", Provider: "custom",
+ Domains: "expiring.example.com", CommonName: "expiring.example.com",
+ ExpiresAt: &expiry, NotBefore: ¬Before,
+ })
+
+ certs, err := cs.CheckExpiringCertificates(30)
+ require.NoError(t, err)
+ assert.Len(t, certs, 1)
+ assert.Equal(t, "Expiring Cert", certs[0].Name)
+ assert.Equal(t, "expiring", certs[0].Status)
+}
+
+func TestCheckExpiringCertificates_WithExpired(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)
+
+ expiry := time.Now().Add(-24 * time.Hour)
+ db.Create(&models.SSLCertificate{
+ UUID: "expired-uuid", Name: "Expired Cert", Provider: "custom",
+ Domains: "expired.example.com", CommonName: "expired.example.com",
+ ExpiresAt: &expiry,
+ })
+
+ certs, err := cs.CheckExpiringCertificates(30)
+ require.NoError(t, err)
+ assert.Len(t, certs, 1)
+ assert.Equal(t, "expired", certs[0].Status)
+}
+
+func TestCheckExpiringCertificates_NoneExpiring(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)
+
+ // Cert expiring in 90 days - outside 30 day window
+ expiry := time.Now().Add(90 * 24 * time.Hour)
+ db.Create(&models.SSLCertificate{
+ UUID: "valid-uuid", Name: "Valid Cert", Provider: "custom",
+ Domains: "valid.example.com", CommonName: "valid.example.com",
+ ExpiresAt: &expiry,
+ })
+
+ certs, err := cs.CheckExpiringCertificates(30)
+ require.NoError(t, err)
+ assert.Empty(t, certs)
+}
+
+// --- checkExpiry with notification service ---
+
+func TestCheckExpiry_WithExpiringCerts(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{},
+ &models.Setting{}, &models.NotificationProvider{},
+ &models.Notification{},
+ ))
+
+ cs := newTestCertificateService(tmpDir, db)
+
+ // Create expiring cert
+ expiry := time.Now().Add(10 * 24 * time.Hour)
+ db.Create(&models.SSLCertificate{
+ UUID: "notify-expiring", Name: "Notify Cert", Provider: "custom",
+ Domains: "notify.example.com", CommonName: "notify.example.com",
+ ExpiresAt: &expiry,
+ })
+
+ notifSvc := NewNotificationService(db, nil)
+ cs.checkExpiry(context.Background(), notifSvc, 30)
+
+ // Verify a notification was created
+ var count int64
+ db.Model(&models.Notification{}).Count(&count)
+ assert.Greater(t, count, int64(0))
+}
+
+func TestCheckExpiry_WithExpiredCerts(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{},
+ &models.Setting{}, &models.NotificationProvider{},
+ &models.Notification{},
+ ))
+
+ cs := newTestCertificateService(tmpDir, db)
+
+ expiry := time.Now().Add(-24 * time.Hour)
+ db.Create(&models.SSLCertificate{
+ UUID: "notify-expired", Name: "Expired Notify", Provider: "custom",
+ Domains: "expired-notify.example.com", CommonName: "expired-notify.example.com",
+ ExpiresAt: &expiry,
+ })
+
+ notifSvc := NewNotificationService(db, nil)
+ cs.checkExpiry(context.Background(), notifSvc, 30)
+
+ var count int64
+ db.Model(&models.Notification{}).Count(&count)
+ assert.Greater(t, count, int64(0))
+}
+
+// --- ListCertificates with chain and proxy host ---
+
+func TestListCertificates_WithChainAndProxyHost(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)
+
+ certPEM, _, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ chainPEM := certPEM + "\n" + certPEM
+
+ expiry := time.Now().Add(90 * 24 * time.Hour)
+ notBefore := time.Now().Add(-1 * time.Hour)
+ certID := uint(99)
+ db.Create(&models.SSLCertificate{
+ ID: certID,
+ UUID: "chain-test-uuid",
+ Name: "Chain Test",
+ Provider: "custom",
+ Domains: "chain.example.com",
+ CommonName: "chain.example.com",
+ Certificate: certPEM,
+ CertificateChain: chainPEM,
+ ExpiresAt: &expiry,
+ NotBefore: ¬Before,
+ })
+
+ db.Create(&models.ProxyHost{
+ Name: "My Proxy",
+ DomainNames: "chain.example.com",
+ CertificateID: &certID,
+ })
+
+ certs, err := cs.ListCertificates()
+ require.NoError(t, err)
+ require.Len(t, certs, 1)
+ assert.Equal(t, 2, certs[0].ChainDepth)
+ assert.True(t, certs[0].InUse)
+ assert.Equal(t, "chain-test-uuid", certs[0].UUID)
+}
+
+// --- UploadCertificate with key ---
+
+func TestUploadCertificate_WithKey(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 := newTestCertServiceWithEnc(t, tmpDir, db)
+
+ certPEM, keyPEM, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ info, err := cs.UploadCertificate("My Upload", certPEM, keyPEM, "")
+ require.NoError(t, err)
+ require.NotNil(t, info)
+ assert.Equal(t, "My Upload", info.Name)
+ assert.True(t, info.HasKey)
+ assert.NotEmpty(t, info.UUID)
+ assert.Equal(t, "custom", info.Provider)
+}
+
+// --- ValidateCertificate with key match ---
+
+func TestValidateCertificate_WithKeyMatch(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)
+
+ certPEM, keyPEM, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ result, err := cs.ValidateCertificate(certPEM, keyPEM, "")
+ require.NoError(t, err)
+ assert.True(t, result.Valid)
+ assert.True(t, result.KeyMatch)
+ assert.Empty(t, result.Errors)
+ assert.Contains(t, result.Warnings, "certificate could not be verified against system roots")
+}
+
+// --- UpdateCertificate with chain depth ---
+
+func TestUpdateCertificate_WithChainDepth(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)
+
+ certPEM, _, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ chainPEM := certPEM + "\n" + certPEM + "\n" + certPEM
+
+ expiry := time.Now().Add(90 * 24 * time.Hour)
+ db.Create(&models.SSLCertificate{
+ UUID: "update-chain-uuid",
+ Name: "Chain Update",
+ Provider: "custom",
+ Domains: "update-chain.example.com",
+ CommonName: "update-chain.example.com",
+ Certificate: certPEM,
+ CertificateChain: chainPEM,
+ ExpiresAt: &expiry,
+ })
+
+ info, err := cs.UpdateCertificate("update-chain-uuid", "Renamed Chain")
+ require.NoError(t, err)
+ assert.Equal(t, "Renamed Chain", info.Name)
+ assert.Equal(t, 3, info.ChainDepth)
+}
+
+// --- ExportCertificate PEM with chain ---
+
+func TestExportCertificate_PEMWithChain(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 := newTestCertServiceWithEnc(t, tmpDir, db)
+
+ certPEM, keyPEM, err := generateSelfSignedCertPEM()
+ require.NoError(t, err)
+
+ encSvc := newTestEncryptionService(t)
+ encKey, err := encSvc.Encrypt([]byte(keyPEM))
+ require.NoError(t, err)
+
+ chainPEM := certPEM
+
+ db.Create(&models.SSLCertificate{
+ UUID: "export-chain-uuid",
+ Name: "Export Chain",
+ Provider: "custom",
+ Domains: "export-chain.example.com",
+ CommonName: "export-chain.example.com",
+ Certificate: certPEM,
+ CertificateChain: chainPEM,
+ PrivateKeyEncrypted: encKey,
+ })
+
+ data, filename, err := cs.ExportCertificate("export-chain-uuid", "pem", true, "")
+ require.NoError(t, err)
+ assert.Equal(t, "Export Chain.pem", filename)
+ assert.Contains(t, string(data), "BEGIN CERTIFICATE")
+ assert.Contains(t, string(data), "BEGIN")
+}
diff --git a/backend/internal/services/certificate_validator_coverage_test.go b/backend/internal/services/certificate_validator_coverage_test.go
new file mode 100644
index 00000000..8aa92725
--- /dev/null
+++ b/backend/internal/services/certificate_validator_coverage_test.go
@@ -0,0 +1,324 @@
+package services
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "software.sslmate.com/src/go-pkcs12"
+)
+
+// --- parsePFXInput ---
+
+func TestParsePFXInput(t *testing.T) {
+ cert, priv, _, _ := makeRSACertAndKey(t, "pfx.test", time.Now().Add(time.Hour))
+ pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+
+ t.Run("valid PFX", func(t *testing.T) {
+ parsed, err := parsePFXInput(pfxData, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.Leaf)
+ assert.NotNil(t, parsed.PrivateKey)
+ assert.Equal(t, FormatPFX, parsed.Format)
+ assert.Contains(t, parsed.CertPEM, "BEGIN CERTIFICATE")
+ assert.Contains(t, parsed.KeyPEM, "PRIVATE KEY")
+ })
+
+ t.Run("PFX with chain", func(t *testing.T) {
+ caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ caTmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(100),
+ Subject: pkix.Name{CommonName: "Test CA"},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ IsCA: true,
+ BasicConstraintsValid: true,
+ KeyUsage: x509.KeyUsageCertSign,
+ }
+ caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
+ require.NoError(t, err)
+ caCert, err := x509.ParseCertificate(caDER)
+ require.NoError(t, err)
+
+ pfxWithChain, err := pkcs12.Modern.Encode(priv, cert, []*x509.Certificate{caCert}, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+
+ parsed, err := parsePFXInput(pfxWithChain, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+ assert.NotEmpty(t, parsed.ChainPEM)
+ assert.Contains(t, parsed.ChainPEM, "BEGIN CERTIFICATE")
+ })
+
+ t.Run("invalid PFX data", func(t *testing.T) {
+ _, err := parsePFXInput([]byte("not-pfx"), "password")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "PFX")
+ })
+
+ t.Run("wrong password", func(t *testing.T) {
+ _, err := parsePFXInput(pfxData, "wrong-password")
+ assert.Error(t, err)
+ })
+}
+
+// --- parseDERInput ---
+
+func TestParseDERInput(t *testing.T) {
+ cert, priv, _, keyPEM := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour))
+
+ t.Run("DER cert only", func(t *testing.T) {
+ parsed, err := parseDERInput(cert.Raw, nil)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.Leaf)
+ assert.Equal(t, FormatDER, parsed.Format)
+ assert.Contains(t, parsed.CertPEM, "BEGIN CERTIFICATE")
+ assert.Nil(t, parsed.PrivateKey)
+ })
+
+ t.Run("DER cert with PEM key", func(t *testing.T) {
+ parsed, err := parseDERInput(cert.Raw, keyPEM)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.PrivateKey)
+ assert.Contains(t, parsed.KeyPEM, "PRIVATE KEY")
+ })
+
+ t.Run("DER cert with DER PKCS8 key", func(t *testing.T) {
+ derKey, err := x509.MarshalPKCS8PrivateKey(priv)
+ require.NoError(t, err)
+ parsed, err := parseDERInput(cert.Raw, derKey)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.PrivateKey)
+ })
+
+ t.Run("DER cert with DER EC key", func(t *testing.T) {
+ ecCert, ecPriv, _, _ := makeECDSACertAndKey(t, "ec-der.test")
+ ecDERKey, err := x509.MarshalECPrivateKey(ecPriv)
+ require.NoError(t, err)
+ parsed, err := parseDERInput(ecCert.Raw, ecDERKey)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.PrivateKey)
+ })
+
+ t.Run("DER cert with invalid key", func(t *testing.T) {
+ _, err := parseDERInput(cert.Raw, []byte("bad-key-data"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "private key")
+ })
+
+ t.Run("invalid DER cert data", func(t *testing.T) {
+ _, err := parseDERInput([]byte("not-der"), nil)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "DER certificate")
+ })
+}
+
+// --- parsePEMInput chain building ---
+
+func TestParsePEMInput_ChainBuilding(t *testing.T) {
+ t.Run("cert with intermediates in cert data", func(t *testing.T) {
+ _, _, certPEM1, _ := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour))
+ _, _, certPEM2, _ := makeRSACertAndKey(t, "intermediate.test", time.Now().Add(time.Hour))
+ combined := append(certPEM1, certPEM2...)
+
+ parsed, err := parsePEMInput(combined, nil, nil)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.Leaf)
+ assert.Len(t, parsed.Intermediates, 1)
+ assert.NotEmpty(t, parsed.ChainPEM)
+ assert.Contains(t, parsed.ChainPEM, "BEGIN CERTIFICATE")
+ })
+
+ t.Run("cert with chain file", func(t *testing.T) {
+ _, _, certPEM, keyPEM := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour))
+ _, _, chainPEM, _ := makeRSACertAndKey(t, "chain.test", time.Now().Add(time.Hour))
+
+ parsed, err := parsePEMInput(certPEM, keyPEM, chainPEM)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.PrivateKey)
+ assert.Len(t, parsed.Intermediates, 1)
+ assert.Equal(t, string(chainPEM), parsed.ChainPEM)
+ })
+
+ t.Run("invalid chain data ignored", func(t *testing.T) {
+ _, _, certPEM, _ := makeRSACertAndKey(t, "leaf.test", time.Now().Add(time.Hour))
+ parsed, err := parsePEMInput(certPEM, nil, []byte("not-pem"))
+ require.NoError(t, err)
+ assert.Empty(t, parsed.Intermediates, "invalid PEM chain should be silently ignored")
+ })
+
+ t.Run("invalid cert data", func(t *testing.T) {
+ _, err := parsePEMInput([]byte("not-pem"), nil, nil)
+ assert.Error(t, err)
+ })
+
+ t.Run("empty PEM block", func(t *testing.T) {
+ emptyPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")})
+ _, err := parsePEMInput(emptyPEM, nil, nil)
+ assert.Error(t, err)
+ })
+}
+
+// --- ConvertPFXToPEM ---
+
+func TestConvertPFXToPEM(t *testing.T) {
+ cert, priv, _, _ := makeRSACertAndKey(t, "pfx-convert.test", time.Now().Add(time.Hour))
+ pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+
+ t.Run("valid PFX", func(t *testing.T) {
+ certPEM, keyPEM, chainPEM, err := ConvertPFXToPEM(pfxData, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+ assert.Contains(t, certPEM, "BEGIN CERTIFICATE")
+ assert.Contains(t, keyPEM, "PRIVATE KEY")
+ assert.Empty(t, chainPEM)
+ })
+
+ t.Run("PFX with chain", func(t *testing.T) {
+ caKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ caTmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(200),
+ Subject: pkix.Name{CommonName: "PFX Test CA"},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(24 * time.Hour),
+ IsCA: true,
+ BasicConstraintsValid: true,
+ KeyUsage: x509.KeyUsageCertSign,
+ }
+ caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
+ require.NoError(t, err)
+ caCert, err := x509.ParseCertificate(caDER)
+ require.NoError(t, err)
+
+ pfxWithChain, err := pkcs12.Modern.Encode(priv, cert, []*x509.Certificate{caCert}, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+
+ certPEM, keyPEM, chainPEM, err := ConvertPFXToPEM(pfxWithChain, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+ assert.Contains(t, certPEM, "BEGIN CERTIFICATE")
+ assert.Contains(t, keyPEM, "PRIVATE KEY")
+ assert.Contains(t, chainPEM, "BEGIN CERTIFICATE")
+ })
+
+ t.Run("invalid PFX", func(t *testing.T) {
+ _, _, _, err := ConvertPFXToPEM([]byte("bad"), "password")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "PFX")
+ })
+}
+
+// --- encodeKeyToPEM ---
+
+func TestEncodeKeyToPEM(t *testing.T) {
+ t.Run("RSA key", func(t *testing.T) {
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ pemStr, err := encodeKeyToPEM(priv)
+ require.NoError(t, err)
+ assert.Contains(t, pemStr, "PRIVATE KEY")
+ })
+
+ t.Run("ECDSA key", func(t *testing.T) {
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ require.NoError(t, err)
+ pemStr, err := encodeKeyToPEM(priv)
+ require.NoError(t, err)
+ assert.Contains(t, pemStr, "PRIVATE KEY")
+ })
+}
+
+// --- ParseCertificateInput for PFX ---
+
+func TestParseCertificateInput_PFX(t *testing.T) {
+ cert, priv, _, _ := makeRSACertAndKey(t, "pfx-parse.test", time.Now().Add(time.Hour))
+ pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+
+ t.Run("PFX format detected and parsed", func(t *testing.T) {
+ parsed, err := ParseCertificateInput(pfxData, nil, nil, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+ assert.NotNil(t, parsed.Leaf)
+ assert.NotNil(t, parsed.PrivateKey)
+ assert.Equal(t, FormatPFX, parsed.Format)
+ })
+}
+
+// --- detectKeyType additional branches ---
+
+func TestDetectKeyType_P384(t *testing.T) {
+ priv, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
+ require.NoError(t, err)
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(99),
+ Subject: pkix.Name{CommonName: "p384.test"},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(time.Hour),
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
+ require.NoError(t, err)
+ cert, err := x509.ParseCertificate(der)
+ require.NoError(t, err)
+
+ assert.Equal(t, "ECDSA-P384", detectKeyType(cert))
+}
+
+// --- parsePEMPrivateKey additional formats ---
+
+func TestParsePEMPrivateKey_PKCS1(t *testing.T) {
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
+
+ key, err := parsePEMPrivateKey(keyPEM)
+ require.NoError(t, err)
+ assert.NotNil(t, key)
+}
+
+func TestParsePEMPrivateKey_EC(t *testing.T) {
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ require.NoError(t, err)
+ ecDER, err := x509.MarshalECPrivateKey(priv)
+ require.NoError(t, err)
+ keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: ecDER})
+
+ key, err := parsePEMPrivateKey(keyPEM)
+ require.NoError(t, err)
+ assert.NotNil(t, key)
+}
+
+func TestParsePEMPrivateKey_Invalid(t *testing.T) {
+ t.Run("no PEM data", func(t *testing.T) {
+ _, err := parsePEMPrivateKey([]byte("not pem"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "no PEM data")
+ })
+
+ t.Run("unsupported key format", func(t *testing.T) {
+ badPEM := pem.EncodeToMemory(&pem.Block{Type: "UNKNOWN KEY", Bytes: []byte("junk")})
+ _, err := parsePEMPrivateKey(badPEM)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported")
+ })
+}
+
+// --- DetectFormat for PFX ---
+
+func TestDetectFormat_PFX(t *testing.T) {
+ cert, priv, _, _ := makeRSACertAndKey(t, "detect-pfx.test", time.Now().Add(time.Hour))
+ pfxData, err := pkcs12.Modern.Encode(priv, cert, nil, pkcs12.DefaultPassword)
+ require.NoError(t, err)
+
+ format := DetectFormat(pfxData)
+ assert.Equal(t, FormatPFX, format, "PFX data should be detected as FormatPFX")
+}
diff --git a/backend/internal/services/certificate_validator_extra_coverage_test.go b/backend/internal/services/certificate_validator_extra_coverage_test.go
new file mode 100644
index 00000000..1bf462d9
--- /dev/null
+++ b/backend/internal/services/certificate_validator_extra_coverage_test.go
@@ -0,0 +1,256 @@
+package services
+
+import (
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "math/big"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// --- ValidateKeyMatch ECDSA ---
+
+func TestValidateKeyMatch_ECDSA_Success(t *testing.T) {
+ cert, _, _, _ := makeECDSACertAndKey(t, "ecdsa-match.test")
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ require.NoError(t, err)
+
+ // Use the actual key that signed the cert
+ ecCert, ecKey, _, _ := makeECDSACertAndKey(t, "ecdsa-ok.test")
+ err = ValidateKeyMatch(ecCert, ecKey)
+ assert.NoError(t, err)
+
+ // Mismatch: different ECDSA key
+ err = ValidateKeyMatch(cert, priv)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "ECDSA key mismatch")
+}
+
+func TestValidateKeyMatch_ECDSA_WrongKeyType(t *testing.T) {
+ cert, _, _, _ := makeECDSACertAndKey(t, "ecdsa-wrong.test")
+ rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ err = ValidateKeyMatch(cert, rsaKey)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "key type mismatch")
+}
+
+// --- ValidateKeyMatch Ed25519 ---
+
+func TestValidateKeyMatch_Ed25519_Success(t *testing.T) {
+ cert, priv, _, _ := makeEd25519CertAndKey(t, "ed25519-ok.test")
+ err := ValidateKeyMatch(cert, priv)
+ assert.NoError(t, err)
+}
+
+func TestValidateKeyMatch_Ed25519_Mismatch(t *testing.T) {
+ cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-mismatch.test")
+ _, otherPriv, err := ed25519.GenerateKey(rand.Reader)
+ require.NoError(t, err)
+
+ err = ValidateKeyMatch(cert, otherPriv)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "Ed25519 key mismatch")
+}
+
+func TestValidateKeyMatch_Ed25519_WrongKeyType(t *testing.T) {
+ cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-wrong.test")
+ rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ err = ValidateKeyMatch(cert, rsaKey)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "key type mismatch")
+}
+
+func TestValidateKeyMatch_UnsupportedKeyType(t *testing.T) {
+ // Create a cert with a nil public key type to trigger the default branch
+ cert := &x509.Certificate{PublicKey: "not-a-real-key"}
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ err = ValidateKeyMatch(cert, key)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "unsupported public key type")
+}
+
+// --- ConvertDERToPEM ---
+
+func TestConvertDERToPEM_Valid(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "der-to-pem.test", time.Now().Add(time.Hour))
+ pemStr, err := ConvertDERToPEM(cert.Raw)
+ require.NoError(t, err)
+ assert.Contains(t, pemStr, "BEGIN CERTIFICATE")
+}
+
+func TestConvertDERToPEM_Invalid(t *testing.T) {
+ _, err := ConvertDERToPEM([]byte("not-der-data"))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid DER")
+}
+
+// --- ConvertPEMToDER ---
+
+func TestConvertPEMToDER_Valid(t *testing.T) {
+ _, _, certPEM, _ := makeRSACertAndKey(t, "pem-to-der.test", time.Now().Add(time.Hour))
+ derData, err := ConvertPEMToDER(string(certPEM))
+ require.NoError(t, err)
+ assert.NotEmpty(t, derData)
+
+ // Verify it's valid DER
+ parsed, err := x509.ParseCertificate(derData)
+ require.NoError(t, err)
+ assert.Equal(t, "pem-to-der.test", parsed.Subject.CommonName)
+}
+
+func TestConvertPEMToDER_NoPEMBlock(t *testing.T) {
+ _, err := ConvertPEMToDER("not-pem-data")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to decode PEM")
+}
+
+func TestConvertPEMToDER_InvalidCert(t *testing.T) {
+ fakePEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte("garbage")}))
+ _, err := ConvertPEMToDER(fakePEM)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid certificate PEM")
+}
+
+// --- ConvertPEMToPFX ---
+
+func TestConvertPEMToPFX_Valid(t *testing.T) {
+ _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pem-to-pfx.test", time.Now().Add(time.Hour))
+ pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), "", "test-password")
+ require.NoError(t, err)
+ assert.NotEmpty(t, pfxData)
+}
+
+func TestConvertPEMToPFX_WithChain(t *testing.T) {
+ _, _, certPEM, keyPEM := makeRSACertAndKey(t, "pfx-chain.test", time.Now().Add(time.Hour))
+ _, _, chainPEM, _ := makeRSACertAndKey(t, "pfx-ca.test", time.Now().Add(time.Hour))
+ pfxData, err := ConvertPEMToPFX(string(certPEM), string(keyPEM), string(chainPEM), "pass")
+ require.NoError(t, err)
+ assert.NotEmpty(t, pfxData)
+}
+
+func TestConvertPEMToPFX_BadCert(t *testing.T) {
+ _, err := ConvertPEMToPFX("not-pem", "not-pem", "", "pass")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "cert PEM")
+}
+
+func TestConvertPEMToPFX_BadKey(t *testing.T) {
+ _, _, certPEM, _ := makeRSACertAndKey(t, "pfx-badkey.test", time.Now().Add(time.Hour))
+ _, err := ConvertPEMToPFX(string(certPEM), "not-pem", "", "pass")
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "key PEM")
+}
+
+// --- ExtractCertificateMetadata ---
+
+func TestExtractCertificateMetadata_Nil(t *testing.T) {
+ result := ExtractCertificateMetadata(nil)
+ assert.Nil(t, result)
+}
+
+func TestExtractCertificateMetadata_Valid(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "metadata.test", time.Now().Add(24*time.Hour))
+ meta := ExtractCertificateMetadata(cert)
+ require.NotNil(t, meta)
+ assert.NotEmpty(t, meta.Fingerprint)
+ assert.NotEmpty(t, meta.SerialNumber)
+ assert.Contains(t, meta.KeyType, "RSA")
+ assert.Contains(t, meta.Domains, "metadata.test")
+}
+
+func TestExtractCertificateMetadata_WithSANs(t *testing.T) {
+ priv, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ tmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(42),
+ Subject: pkix.Name{CommonName: "san.test", Organization: []string{"Test Org"}},
+ Issuer: pkix.Name{Organization: []string{"Test Issuer"}},
+ NotBefore: time.Now(),
+ NotAfter: time.Now().Add(time.Hour),
+ DNSNames: []string{"san.test", "alt.test", "other.test"},
+ }
+ der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
+ require.NoError(t, err)
+ cert, err := x509.ParseCertificate(der)
+ require.NoError(t, err)
+
+ meta := ExtractCertificateMetadata(cert)
+ require.NotNil(t, meta)
+ assert.Contains(t, meta.Domains, "san.test")
+ assert.Contains(t, meta.Domains, "alt.test")
+ assert.Contains(t, meta.Domains, "other.test")
+ assert.Equal(t, "Test Org", meta.IssuerOrg)
+}
+
+// --- detectKeyType ---
+
+func TestDetectKeyType_Ed25519(t *testing.T) {
+ cert, _, _, _ := makeEd25519CertAndKey(t, "ed25519-type.test")
+ assert.Equal(t, "Ed25519", detectKeyType(cert))
+}
+
+func TestDetectKeyType_RSA(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "rsa-type.test", time.Now().Add(time.Hour))
+ kt := detectKeyType(cert)
+ assert.Contains(t, kt, "RSA-")
+}
+
+func TestDetectKeyType_ECDSA_P256(t *testing.T) {
+ cert, _, _, _ := makeECDSACertAndKey(t, "p256-type.test")
+ assert.Equal(t, "ECDSA-P256", detectKeyType(cert))
+}
+
+// --- formatSerial ---
+
+func TestFormatSerial_Nil(t *testing.T) {
+ assert.Equal(t, "", formatSerial(nil))
+}
+
+func TestFormatSerial_Value(t *testing.T) {
+ result := formatSerial(big.NewInt(256))
+ assert.NotEmpty(t, result)
+ assert.Contains(t, result, ":")
+}
+
+// --- formatFingerprint ---
+
+func TestFormatFingerprint_Normal(t *testing.T) {
+ result := formatFingerprint("aabbccdd")
+ assert.Equal(t, "AA:BB:CC:DD", result)
+}
+
+func TestFormatFingerprint_OddLength(t *testing.T) {
+ result := formatFingerprint("aabbc")
+ assert.Contains(t, result, "AA:BB")
+}
+
+// --- DetectFormat DER ---
+
+func TestDetectFormat_DER(t *testing.T) {
+ cert, _, _, _ := makeRSACertAndKey(t, "detect-der.test", time.Now().Add(time.Hour))
+ format := DetectFormat(cert.Raw)
+ assert.Equal(t, FormatDER, format)
+}
+
+func TestDetectFormat_PEM(t *testing.T) {
+ _, _, certPEM, _ := makeRSACertAndKey(t, "detect-pem.test", time.Now().Add(time.Hour))
+ format := DetectFormat(certPEM)
+ assert.Equal(t, FormatPEM, format)
+}
+
+
diff --git a/frontend/src/components/__tests__/CertificateList.test.tsx b/frontend/src/components/__tests__/CertificateList.test.tsx
index 7548f5a9..37f2e735 100644
--- a/frontend/src/components/__tests__/CertificateList.test.tsx
+++ b/frontend/src/components/__tests__/CertificateList.test.tsx
@@ -361,4 +361,83 @@ describe('CertificateList', () => {
await user.click(screen.getByText('Expires'))
expect(getRowNames()).toEqual(['Zulu', 'Alpha'])
})
+
+ it('shows success toast when single delete succeeds', async () => {
+ const { toast } = await import('../../utils/toast')
+ deleteMutateFn.mockImplementation((_uuid: string, { onSuccess }: { onSuccess: () => void }) => {
+ onSuccess()
+ })
+ const user = userEvent.setup()
+ renderWithClient()
+ const rows = await screen.findAllByRole('row')
+ const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
+ await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('certificates.deleteSuccess'))
+ })
+
+ it('shows error toast when single delete fails', async () => {
+ const { toast } = await import('../../utils/toast')
+ deleteMutateFn.mockImplementation((_uuid: string, { onError }: { onError: (e: Error) => void }) => {
+ onError(new Error('Network failure'))
+ })
+ const user = userEvent.setup()
+ renderWithClient()
+ const rows = await screen.findAllByRole('row')
+ const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
+ await user.click(within(customRow).getByRole('button', { name: 'certificates.deleteTitle' }))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: 'certificates.deleteButton' }))
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.deleteFailed: Network failure'))
+ })
+
+ it('shows success toast when all bulk deletes succeed', async () => {
+ const { toast } = await import('../../utils/toast')
+ bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onSuccess }: { onSuccess: (data: { succeeded: number; failed: number }) => void }) => {
+ onSuccess({ succeeded: 2, failed: 0 })
+ })
+ const user = userEvent.setup()
+ renderWithClient()
+ const rows = await screen.findAllByRole('row')
+ await user.click(within(rows.find(r => r.textContent?.includes('CustomCert'))!).getByRole('checkbox'))
+ await user.click(within(rows.find(r => r.textContent?.includes('LE Staging'))!).getByRole('checkbox'))
+ await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
+ await waitFor(() => expect(toast.success).toHaveBeenCalledWith('certificates.bulkDeleteSuccess'))
+ })
+
+ it('shows error toast when bulk delete fails entirely', async () => {
+ const { toast } = await import('../../utils/toast')
+ bulkDeleteMutateFn.mockImplementation((_uuids: string[], { onError }: { onError: () => void }) => {
+ onError()
+ })
+ const user = userEvent.setup()
+ renderWithClient()
+ const rows = await screen.findAllByRole('row')
+ await user.click(within(rows.find(r => r.textContent?.includes('CustomCert'))!).getByRole('checkbox'))
+ await user.click(screen.getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
+ const dialog = await screen.findByRole('dialog')
+ await user.click(within(dialog).getByRole('button', { name: /certificates\.bulkDeleteButton/i }))
+ await waitFor(() => expect(toast.error).toHaveBeenCalledWith('certificates.bulkDeleteFailed'))
+ })
+
+ it('opens detail dialog when view button is clicked', async () => {
+ const user = userEvent.setup()
+ renderWithClient()
+ const rows = await screen.findAllByRole('row')
+ const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
+ await user.click(within(customRow).getByTestId('view-cert-cert-1'))
+ expect(await screen.findByRole('dialog')).toBeInTheDocument()
+ })
+
+ it('opens export dialog when export button is clicked', async () => {
+ const user = userEvent.setup()
+ renderWithClient()
+ const rows = await screen.findAllByRole('row')
+ const customRow = rows.find(r => r.textContent?.includes('CustomCert'))!
+ await user.click(within(customRow).getByTestId('export-cert-cert-1'))
+ expect(await screen.findByRole('dialog')).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx
index 7ed8c761..a1430bfb 100644
--- a/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx
+++ b/frontend/src/components/dialogs/__tests__/CertificateUploadDialog.test.tsx
@@ -310,4 +310,66 @@ describe('CertificateUploadDialog', () => {
expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
})
})
+
+ it('resets validation when key file changes', async () => {
+ const mockResult = {
+ valid: true,
+ common_name: 'test.com',
+ domains: ['test.com'],
+ issuer_org: 'CA',
+ expires_at: '2026-01-01',
+ key_match: false,
+ chain_valid: false,
+ chain_depth: 0,
+ warnings: [],
+ errors: [],
+ }
+ validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => {
+ opts.onSuccess(mockResult)
+ })
+
+ renderDialog()
+ const certInput = document.getElementById('cert-file') as HTMLInputElement
+ await userEvent.upload(certInput, createFile())
+ await userEvent.click(await screen.findByTestId('validate-certificate-btn'))
+ expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy()
+
+ const keyInput = document.getElementById('key-file') as HTMLInputElement
+ const keyFile = new File(['key-data'], 'private.key', { type: 'application/x-pem-file' })
+ await userEvent.upload(keyInput, keyFile)
+ await waitFor(() => {
+ expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
+ })
+ })
+
+ it('resets validation when chain file changes', async () => {
+ const mockResult = {
+ valid: true,
+ common_name: 'test.com',
+ domains: ['test.com'],
+ issuer_org: 'CA',
+ expires_at: '2026-01-01',
+ key_match: false,
+ chain_valid: false,
+ chain_depth: 0,
+ warnings: [],
+ errors: [],
+ }
+ validateMutateFn.mockImplementation((_params: unknown, opts: { onSuccess: (r: typeof mockResult) => void }) => {
+ opts.onSuccess(mockResult)
+ })
+
+ renderDialog()
+ const certInput = document.getElementById('cert-file') as HTMLInputElement
+ await userEvent.upload(certInput, createFile())
+ await userEvent.click(await screen.findByTestId('validate-certificate-btn'))
+ expect(await screen.findByTestId('certificate-validation-preview')).toBeTruthy()
+
+ const chainInput = document.getElementById('chain-file') as HTMLInputElement
+ const chainFile = new File(['chain-data'], 'chain.pem', { type: 'application/x-pem-file' })
+ await userEvent.upload(chainInput, chainFile)
+ await waitFor(() => {
+ expect(screen.queryByTestId('certificate-validation-preview')).toBeFalsy()
+ })
+ })
})
diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx
index 91445bf7..06883756 100644
--- a/frontend/src/pages/__tests__/Dashboard.test.tsx
+++ b/frontend/src/pages/__tests__/Dashboard.test.tsx
@@ -73,4 +73,15 @@ describe('Dashboard page', () => {
expect(await screen.findByText('Error')).toBeInTheDocument()
})
+
+ it('handles certificates with missing domains field', async () => {
+ // The top-level mock returns certs with "domain" (singular) but Dashboard
+ // reads "domains" (plural), so the !cert.domains guard on line 48 is
+ // already exercised by every render. Re-render and verify it doesn't crash.
+ renderWithQueryClient()
+
+ expect(await screen.findByText('Dashboard')).toBeInTheDocument()
+ // "1 valid" still renders even though cert.domains is undefined
+ expect(screen.getByText('1 valid')).toBeInTheDocument()
+ })
})
diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx
index f6a32fb9..5fa138c2 100644
--- a/frontend/src/pages/__tests__/UsersPage.test.tsx
+++ b/frontend/src/pages/__tests__/UsersPage.test.tsx
@@ -453,12 +453,12 @@ describe('UsersPage', () => {
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
- }, { timeout: 1000 })
+ }, { timeout: 2000 })
// Look for the preview URL content with ellipsis replacing the token
await waitFor(() => {
expect(screen.getByText('https://charon.example.com/accept-invite?token=...')).toBeInTheDocument()
- }, { timeout: 1000 })
+ }, { timeout: 2000 })
})
it('debounces URL preview for 500ms', async () => {
@@ -521,12 +521,16 @@ describe('UsersPage', () => {
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
+ await waitFor(() => {
+ expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
+ }, { timeout: 2000 })
+
await waitFor(() => {
const preview = screen.getByText('https://example.com/accept-invite?token=...')
expect(preview.textContent).toContain('...')
expect(preview.textContent).not.toContain('SAMPLE_TOKEN_PREVIEW')
- }, { timeout: 1000 })
+ }, { timeout: 2000 })
})
it('shows warning when not configured', async () => {
@@ -550,11 +554,15 @@ describe('UsersPage', () => {
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
+ await waitFor(() => {
+ expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
+ }, { timeout: 2000 })
+
await waitFor(() => {
// Look for link to system settings
const link = screen.getByRole('link')
expect(link.getAttribute('href')).toContain('/settings/system')
- }, { timeout: 1000 })
+ }, { timeout: 2000 })
})
it('does not show preview when email is invalid', async () => {
@@ -590,14 +598,9 @@ describe('UsersPage', () => {
const emailInput = screen.getByPlaceholderText('user@example.com')
await user.type(emailInput, 'test@example.com')
- // Wait for debounce
- await act(async () => {
- await new Promise(resolve => setTimeout(resolve, 600))
- })
-
await waitFor(() => {
expect(client.post).toHaveBeenCalledWith('/users/preview-invite-url', { email: 'test@example.com' })
- }, { timeout: 1000 })
+ }, { timeout: 2000 })
// Verify preview is not displayed after error
const previewQuery = screen.queryByText(/accept-invite/)