From 0ae1dc998a7d4739d75edc5a592a58553394dbe7 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Mon, 13 Apr 2026 12:04:47 +0000 Subject: [PATCH] test: update certificate deletion tests to use string UUIDs instead of integers --- .../certificate_handler_coverage_test.go | 398 +++++++++++++ .../certificate_service_coverage_test.go | 524 ++++++++++++++++++ .../ProxyHosts-cert-cleanup.test.tsx | 6 +- .../ProxyHosts-coverage-isolated.test.tsx | 2 +- 4 files changed, 926 insertions(+), 4 deletions(-) create mode 100644 backend/internal/services/certificate_service_coverage_test.go diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go index 30131600..7baea67d 100644 --- a/backend/internal/api/handlers/certificate_handler_coverage_test.go +++ b/backend/internal/api/handlers/certificate_handler_coverage_test.go @@ -1,12 +1,18 @@ package handlers import ( + "bytes" + "encoding/json" + "mime/multipart" "net/http" "net/http/httptest" + "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/Wikid82/charon/backend/internal/models" "github.com/Wikid82/charon/backend/internal/services" @@ -179,3 +185,395 @@ func TestCertificateHandler_DBSetupOrdering(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } + +// --- Get handler tests --- + +func TestCertificateHandler_Get_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + expiry := time.Now().Add(30 * 24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "get-uuid-1", Name: "Get Test", Provider: "custom", Domains: "get.example.com", ExpiresAt: &expiry}) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.GET("/api/certificates/:uuid", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/get-uuid-1", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "get-uuid-1") + assert.Contains(t, w.Body.String(), "Get Test") +} + +func TestCertificateHandler_Get_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.GET("/api/certificates/:uuid", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/nonexistent-uuid", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Get_EmptyUUID(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + // Route with empty uuid param won't match, test the handler directly with blank uuid + r.GET("/api/certificates/", h.Get) + + req := httptest.NewRequest(http.MethodGet, "/api/certificates/", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Empty uuid should return 400 or 404 depending on router handling + assert.True(t, w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound) +} + +// --- SetDB test --- + +func TestCertificateHandler_SetDB(t *testing.T) { + db := OpenTestDBWithMigrations(t) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + assert.Nil(t, h.db) + + h.SetDB(db) + assert.NotNil(t, h.db) +} + +// --- Update handler tests --- + +func TestCertificateHandler_Update_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + expiry := time.Now().Add(30 * 24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "upd-uuid-1", Name: "Old Name", Provider: "custom", Domains: "update.example.com", ExpiresAt: &expiry}) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/upd-uuid-1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "New Name") +} + +func TestCertificateHandler_Update_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{"name": "New Name"}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/nonexistent-uuid", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Update_BadJSON(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + req := httptest.NewRequest(http.MethodPut, "/api/certificates/some-uuid", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Update_MissingName(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.PUT("/api/certificates/:uuid", h.Update) + + body, _ := json.Marshal(map[string]string{}) + req := httptest.NewRequest(http.MethodPut, "/api/certificates/some-uuid", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +// --- Validate handler tests --- + +func TestCertificateHandler_Validate_Success(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + 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)) + _ = 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) + assert.Contains(t, w.Body.String(), "valid") +} + +func TestCertificateHandler_Validate_NoCertFile(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/validate", strings.NewReader("")) + req.Header.Set("Content-Type", "multipart/form-data") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Validate_CertOnly(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/validate", h.Validate) + + certPEM, _, 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)) + _ = 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) +} + +// --- Export handler tests --- + +func TestCertificateHandler_Export_EmptyUUID(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + // Use a route that provides :uuid param as empty would not match normal routing + req := httptest.NewRequest(http.MethodPost, "/api/certificates//export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // Router won't match empty uuid, so 404 or redirect + assert.True(t, w.Code == http.StatusNotFound || w.Code == http.StatusMovedPermanently || w.Code == http.StatusBadRequest) +} + +func TestCertificateHandler_Export_BadJSON(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + req := httptest.NewRequest(http.MethodPost, "/api/certificates/some-uuid/export", strings.NewReader("{bad")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCertificateHandler_Export_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/nonexistent-uuid/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Export_PEMSuccess(t *testing.T) { + db := OpenTestDBWithMigrations(t) + certPEM, _, err := generateSelfSignedCertPEM() + require.NoError(t, err) + + cert := models.SSLCertificate{UUID: "export-uuid-1", Name: "Export Test", Provider: "custom", Domains: "export.example.com", Certificate: certPEM} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-1/export", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Disposition"), "Export Test.pem") +} + +func TestCertificateHandler_Export_IncludeKeyNoPassword(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "export-uuid-2", Name: "Key Test", Provider: "custom", Domains: "key.example.com"} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem", "include_key": true}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-2/export", bytes.NewBuffer(body)) + 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(), "password required") +} + +func TestCertificateHandler_Export_IncludeKeyNoDBSet(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "export-uuid-3", Name: "No DB Test", Provider: "custom", Domains: "nodb.example.com"} + db.Create(&cert) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + // h.db is nil - not set via SetDB + r.POST("/api/certificates/:uuid/export", h.Export) + + body, _ := json.Marshal(map[string]any{"format": "pem", "include_key": true, "password": "test123"}) + req := httptest.NewRequest(http.MethodPost, "/api/certificates/export-uuid-3/export", bytes.NewBuffer(body)) + 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") +} + +// --- Delete via UUID path tests --- + +func TestCertificateHandler_Delete_UUIDPath_NotFound(t *testing.T) { + db := OpenTestDBWithMigrations(t) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.DELETE("/api/certificates/:uuid", h.Delete) + + // Valid UUID format but does not exist + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/00000000-0000-0000-0000-000000000001", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestCertificateHandler_Delete_UUIDPath_InUse(t *testing.T) { + db := OpenTestDBWithMigrations(t) + cert := models.SSLCertificate{UUID: "11111111-1111-1111-1111-111111111111", Name: "InUse UUID", Provider: "custom", Domains: "uuid-inuse.example.com"} + db.Create(&cert) + + ph := models.ProxyHost{UUID: "ph-uuid-del", Name: "Proxy", DomainNames: "uuid-inuse.example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID} + db.Create(&ph) + + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db, nil) + h := NewCertificateHandler(svc, nil, nil) + r.DELETE("/api/certificates/:uuid", h.Delete) + + req := httptest.NewRequest(http.MethodDelete, "/api/certificates/11111111-1111-1111-1111-111111111111", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) +} + +// --- sanitizeCertRef tests --- + +func TestSanitizeCertRef(t *testing.T) { + assert.Equal(t, "00000000-0000-0000-0000-000000000001", sanitizeCertRef("00000000-0000-0000-0000-000000000001")) + assert.Equal(t, "123", sanitizeCertRef("123")) + assert.Equal(t, "[invalid-ref]", sanitizeCertRef("not-valid")) + assert.Equal(t, "0", sanitizeCertRef("0")) +} diff --git a/backend/internal/services/certificate_service_coverage_test.go b/backend/internal/services/certificate_service_coverage_test.go new file mode 100644 index 00000000..22c02937 --- /dev/null +++ b/backend/internal/services/certificate_service_coverage_test.go @@ -0,0 +1,524 @@ +package services + +import ( + "context" + "encoding/base64" + "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/crypto" + "github.com/Wikid82/charon/backend/internal/models" +) + +// newTestEncryptionService creates a real EncryptionService for tests. +func newTestEncryptionService(t *testing.T) *crypto.EncryptionService { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + keyB64 := base64.StdEncoding.EncodeToString(key) + svc, err := crypto.NewEncryptionService(keyB64) + require.NoError(t, err) + return svc +} + +func newTestCertServiceWithEnc(t *testing.T, dataDir string, db *gorm.DB) *CertificateService { + t.Helper() + encSvc := newTestEncryptionService(t) + return &CertificateService{ + dataDir: dataDir, + db: db, + encSvc: encSvc, + scanTTL: 5 * time.Minute, + } +} + +func seedCertWithKey(t *testing.T, db *gorm.DB, encSvc *crypto.EncryptionService, uuid, name, domain string, expiry time.Time) models.SSLCertificate { + t.Helper() + certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry) + + encKey, err := encSvc.Encrypt(keyPEM) + require.NoError(t, err) + + cert := models.SSLCertificate{ + UUID: uuid, + Name: name, + Provider: "custom", + Domains: domain, + CommonName: domain, + Certificate: string(certPEM), + PrivateKeyEncrypted: encKey, + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + return cert +} + +func TestCertificateService_GetCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.GetCertificate("nonexistent-uuid") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("found with no hosts", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + notBefore := time.Now().Add(-time.Hour) + cert := models.SSLCertificate{ + UUID: "get-cert-1", + Name: "Test Cert", + Provider: "custom", + Domains: "get.example.com", + CommonName: "get.example.com", + ExpiresAt: &expiry, + NotBefore: ¬Before, + } + require.NoError(t, db.Create(&cert).Error) + + detail, err := cs.GetCertificate("get-cert-1") + require.NoError(t, err) + assert.Equal(t, "get-cert-1", detail.UUID) + assert.Equal(t, "Test Cert", detail.Name) + assert.Equal(t, "get.example.com", detail.CommonName) + assert.False(t, detail.InUse) + assert.Empty(t, detail.AssignedHosts) + }) + + t.Run("found with assigned host", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := models.SSLCertificate{ + UUID: "get-cert-2", + Name: "Assigned Cert", + Provider: "custom", + Domains: "assigned.example.com", + CommonName: "assigned.example.com", + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + + ph := models.ProxyHost{ + UUID: "ph-assigned", + Name: "My Proxy", + DomainNames: "assigned.example.com", + ForwardHost: "localhost", + ForwardPort: 8080, + CertificateID: &cert.ID, + } + require.NoError(t, db.Create(&ph).Error) + + detail, err := cs.GetCertificate("get-cert-2") + require.NoError(t, err) + assert.True(t, detail.InUse) + require.Len(t, detail.AssignedHosts, 1) + assert.Equal(t, "My Proxy", detail.AssignedHosts[0].Name) + }) + + t.Run("nil expiry and not_before", func(t *testing.T) { + cert := models.SSLCertificate{ + UUID: "get-cert-3", + Name: "No Dates", + Provider: "custom", + Domains: "nodates.example.com", + } + require.NoError(t, db.Create(&cert).Error) + + detail, err := cs.GetCertificate("get-cert-3") + require.NoError(t, err) + assert.True(t, detail.ExpiresAt.IsZero()) + assert.True(t, detail.NotBefore.IsZero()) + }) +} + +func TestCertificateService_ValidateCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("valid cert with key", func(t *testing.T) { + certPEM, keyPEM := generateTestCertAndKey(t, "validate.example.com", time.Now().Add(24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), string(keyPEM), "") + require.NoError(t, err) + assert.True(t, result.Valid) + assert.True(t, result.KeyMatch) + assert.Empty(t, result.Errors) + }) + + t.Run("invalid cert data", func(t *testing.T) { + result, err := cs.ValidateCertificate("not-a-cert", "", "") + require.NoError(t, err) + assert.False(t, result.Valid) + assert.NotEmpty(t, result.Errors) + }) + + t.Run("valid cert without key", func(t *testing.T) { + certPEM := generateTestCert(t, "nokey.example.com", time.Now().Add(24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), "", "") + require.NoError(t, err) + assert.True(t, result.Valid) + assert.False(t, result.KeyMatch) + assert.Empty(t, result.Errors) + }) + + t.Run("expired cert", func(t *testing.T) { + certPEM := generateTestCert(t, "expired.example.com", time.Now().Add(-24*time.Hour)) + result, err := cs.ValidateCertificate(string(certPEM), "", "") + require.NoError(t, err) + assert.NotEmpty(t, result.Warnings) + }) +} + +func TestCertificateService_UpdateCertificate(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.UpdateCertificate("nonexistent-uuid", "New Name") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("successful rename", func(t *testing.T) { + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := models.SSLCertificate{ + UUID: "update-cert-1", + Name: "Old Name", + Provider: "custom", + Domains: "update.example.com", + CommonName: "update.example.com", + ExpiresAt: &expiry, + } + require.NoError(t, db.Create(&cert).Error) + + info, err := cs.UpdateCertificate("update-cert-1", "New Name") + require.NoError(t, err) + assert.Equal(t, "New Name", info.Name) + assert.Equal(t, "update-cert-1", info.UUID) + assert.Equal(t, "custom", info.Provider) + }) + + t.Run("updates persist", func(t *testing.T) { + var cert models.SSLCertificate + require.NoError(t, db.Where("uuid = ?", "update-cert-1").First(&cert).Error) + assert.Equal(t, "New Name", cert.Name) + }) + + t.Run("nil expiry and not_before", func(t *testing.T) { + cert := models.SSLCertificate{ + UUID: "update-cert-2", + Name: "No Dates Cert", + Provider: "custom", + Domains: "nodates-update.example.com", + } + require.NoError(t, db.Create(&cert).Error) + + info, err := cs.UpdateCertificate("update-cert-2", "Renamed No Dates") + require.NoError(t, err) + assert.Equal(t, "Renamed No Dates", info.Name) + assert.True(t, info.ExpiresAt.IsZero()) + }) +} + +func TestCertificateService_IsCertificateInUseByUUID(t *testing.T) { + tmpDir := t.TempDir() + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name()) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})) + + cs := newTestCertificateService(tmpDir, db) + + t.Run("not found", func(t *testing.T) { + _, err := cs.IsCertificateInUseByUUID("nonexistent-uuid") + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("not in use", func(t *testing.T) { + cert := models.SSLCertificate{UUID: "inuse-1", Name: "Free Cert", Provider: "custom", Domains: "free.example.com"} + require.NoError(t, db.Create(&cert).Error) + + inUse, err := cs.IsCertificateInUseByUUID("inuse-1") + require.NoError(t, err) + assert.False(t, inUse) + }) + + t.Run("in use", func(t *testing.T) { + cert := models.SSLCertificate{UUID: "inuse-2", Name: "Used Cert", Provider: "custom", Domains: "used.example.com"} + require.NoError(t, db.Create(&cert).Error) + + ph := models.ProxyHost{UUID: "ph-inuse", Name: "Using Proxy", DomainNames: "used.example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID} + require.NoError(t, db.Create(&ph).Error) + + inUse, err := cs.IsCertificateInUseByUUID("inuse-2") + require.NoError(t, err) + assert.True(t, inUse) + }) +} + +func TestCertificateService_DeleteCertificateByID(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: "del-by-id-1", Name: "Delete By ID", Provider: "custom", Domains: "delbyid.example.com"} + require.NoError(t, db.Create(&cert).Error) + + err = cs.DeleteCertificateByID(cert.ID) + require.NoError(t, err) + + var found models.SSLCertificate + err = db.Where("uuid = ?", "del-by-id-1").First(&found).Error + assert.Error(t, err) +} + +func TestCertificateService_ExportCertificate(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{})) + + encSvc := newTestEncryptionService(t) + cs := newTestCertServiceWithEnc(t, tmpDir, db) + + domain := "export.example.com" + expiry := time.Now().Add(30 * 24 * time.Hour) + cert := seedCertWithKey(t, db, encSvc, "export-cert-1", "Export Cert", domain, expiry) + + t.Run("not found", func(t *testing.T) { + _, _, err := cs.ExportCertificate("nonexistent", "pem", false) + assert.ErrorIs(t, err, ErrCertNotFound) + }) + + t.Run("pem without key", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", false) + require.NoError(t, err) + assert.Equal(t, "Export Cert.pem", filename) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + }) + + t.Run("pem with key", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pem", true) + require.NoError(t, err) + assert.Equal(t, "Export Cert.pem", filename) + assert.Contains(t, string(data), "BEGIN CERTIFICATE") + assert.Contains(t, string(data), "PRIVATE KEY") + }) + + t.Run("der format", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "der", false) + require.NoError(t, err) + assert.Equal(t, "Export Cert.der", filename) + assert.NotEmpty(t, data) + }) + + t.Run("pfx format", func(t *testing.T) { + data, filename, err := cs.ExportCertificate(cert.UUID, "pfx", false) + require.NoError(t, err) + assert.Equal(t, "Export Cert.pfx", filename) + assert.NotEmpty(t, data) + }) + + t.Run("unsupported format", func(t *testing.T) { + _, _, err := cs.ExportCertificate(cert.UUID, "jks", false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported export format") + }) + + t.Run("empty name uses fallback", func(t *testing.T) { + noNameCert := seedCertWithKey(t, db, encSvc, "export-noname", "", domain, expiry) + _, filename, err := cs.ExportCertificate(noNameCert.UUID, "pem", false) + require.NoError(t, err) + assert.Equal(t, "certificate.pem", filename) + }) +} + +func TestCertificateService_GetDecryptedPrivateKey(t *testing.T) { + encSvc := newTestEncryptionService(t) + + t.Run("no encrypted key", func(t *testing.T) { + cs := &CertificateService{encSvc: encSvc} + cert := &models.SSLCertificate{PrivateKeyEncrypted: ""} + _, err := cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no encrypted private key") + }) + + t.Run("no encryption service", func(t *testing.T) { + cs := &CertificateService{encSvc: nil} + cert := &models.SSLCertificate{PrivateKeyEncrypted: "some-data"} + _, err := cs.GetDecryptedPrivateKey(cert) + assert.Error(t, err) + assert.Contains(t, err.Error(), "encryption service not configured") + }) + + t.Run("successful decryption", func(t *testing.T) { + cs := &CertificateService{encSvc: encSvc} + plaintext := "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----" //nolint:gosec // test data, not real credentials + encrypted, err := encSvc.Encrypt([]byte(plaintext)) + require.NoError(t, err) + + cert := &models.SSLCertificate{PrivateKeyEncrypted: encrypted} + result, err := cs.GetDecryptedPrivateKey(cert) + require.NoError(t, err) + assert.Equal(t, plaintext, result) + }) +} + +func TestCertificateService_CheckExpiringCertificates(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 certs with different expiry states + expiringSoon := time.Now().Add(5 * 24 * time.Hour) + expired := time.Now().Add(-24 * time.Hour) + farFuture := time.Now().Add(365 * 24 * time.Hour) + + db.Create(&models.SSLCertificate{UUID: "exp-soon", Name: "Expiring Soon", Provider: "custom", Domains: "soon.example.com", ExpiresAt: &expiringSoon}) + db.Create(&models.SSLCertificate{UUID: "exp-past", Name: "Already Expired", Provider: "custom", Domains: "expired.example.com", ExpiresAt: &expired}) + db.Create(&models.SSLCertificate{UUID: "exp-far", Name: "Far Future", Provider: "custom", Domains: "far.example.com", ExpiresAt: &farFuture}) + // ACME certs should not be included (only custom) + db.Create(&models.SSLCertificate{UUID: "exp-le", Name: "LE Cert", Provider: "letsencrypt", Domains: "le.example.com", ExpiresAt: &expiringSoon}) + + t.Run("30 day window", func(t *testing.T) { + certs, err := cs.CheckExpiringCertificates(30) + require.NoError(t, err) + assert.Len(t, certs, 2) // expiringSoon and expired + + foundSoon := false + foundExpired := false + for _, c := range certs { + if c.UUID == "exp-soon" { + foundSoon = true + } + if c.UUID == "exp-past" { + foundExpired = true + } + } + assert.True(t, foundSoon) + assert.True(t, foundExpired) + }) + + t.Run("1 day window", func(t *testing.T) { + certs, err := cs.CheckExpiringCertificates(1) + require.NoError(t, err) + assert.Len(t, certs, 1) // only the expired one + assert.Equal(t, "exp-past", certs[0].UUID) + }) +} + +func TestCertificateService_CheckExpiry(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) + ns := NewNotificationService(db, nil) + + expiringSoon := time.Now().Add(5 * 24 * time.Hour) + expired := time.Now().Add(-24 * time.Hour) + db.Create(&models.SSLCertificate{UUID: "chk-soon", Name: "Expiring", Provider: "custom", Domains: "chksoon.example.com", ExpiresAt: &expiringSoon}) + db.Create(&models.SSLCertificate{UUID: "chk-past", Name: "Expired", Provider: "custom", Domains: "chkpast.example.com", ExpiresAt: &expired}) + + t.Run("nil notification service", func(t *testing.T) { + cs.checkExpiry(context.Background(), nil, 30) + }) + + t.Run("creates notifications for expiring certs", func(t *testing.T) { + cs.checkExpiry(context.Background(), ns, 30) + + var notifications []models.Notification + db.Find(¬ifications) + assert.GreaterOrEqual(t, len(notifications), 2) + }) +} + +func TestCertificateService_MigratePrivateKeys(t *testing.T) { + t.Run("no encryption service", func(t *testing.T) { + cs := &CertificateService{encSvc: nil} + err := cs.MigratePrivateKeys() + require.NoError(t, err) + }) + + t.Run("no keys to migrate", func(t *testing.T) { + 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 referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + encSvc := newTestEncryptionService(t) + cs := &CertificateService{db: db, encSvc: encSvc} + + err = cs.MigratePrivateKeys() + require.NoError(t, err) + }) + + t.Run("migrates plaintext key", func(t *testing.T) { + 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 referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + // Insert cert with plaintext key using raw SQL{})) + // MigratePrivateKeys uses raw SQL referencing private_key column (gorm:"-" tag) + require.NoError(t, db.Exec("ALTER TABLE ssl_certificates ADD COLUMN private_key TEXT DEFAULT ''").Error) + + // Insert cert with plaintext key using raw SQL + require.NoError(t, db.Exec( + "INSERT INTO ssl_certificates (uuid, name, provider, domains, private_key) VALUES (?, ?, ?, ?, ?)", + "migrate-1", "Migrate Test", "custom", "migrate.example.com", "plaintext-key-data", + ).Error) + + encSvc := newTestEncryptionService(t) + cs := &CertificateService{db: db, encSvc: encSvc} + + err = cs.MigratePrivateKeys() + require.NoError(t, err) + + // Verify the key was encrypted and plaintext cleared + type rawRow struct { + PrivateKey string `gorm:"column:private_key"` + PrivateKeyEnc string `gorm:"column:private_key_enc"` + } + var row rawRow + require.NoError(t, db.Raw("SELECT private_key, private_key_enc FROM ssl_certificates WHERE uuid = ?", "migrate-1").Scan(&row).Error) + assert.Empty(t, row.PrivateKey) + assert.NotEmpty(t, row.PrivateKeyEnc) + }) +} diff --git a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx index 6e869fc1..245d0fa4 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-cert-cleanup.test.tsx @@ -129,7 +129,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) }) @@ -303,7 +303,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) // Toast should show error about certificate but host was deleted @@ -366,7 +366,7 @@ describe('ProxyHosts - Certificate Cleanup Prompts', () => { await waitFor(() => { expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h1') - expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith(1) + expect(certificatesApi.deleteCertificate).toHaveBeenCalledWith('cert-1') }) expect(proxyHostsApi.deleteProxyHost).toHaveBeenCalledWith('h2') }) diff --git a/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx index d9c4641d..bf160e6c 100644 --- a/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx +++ b/frontend/src/pages/__tests__/ProxyHosts-coverage-isolated.test.tsx @@ -72,7 +72,7 @@ describe('ProxyHosts page - coverage targets (isolated)', () => { vi.doMock('../../hooks/useCertificates', () => ({ useCertificates: vi.fn(() => ({ certificates: [ - { id: 1, name: 'StagingCert', domain: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' } + { id: 1, name: 'StagingCert', domains: 'staging.example.com', status: 'untrusted', provider: 'letsencrypt-staging' } ], isLoading: false, error: null,