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/)