From c4e8d6c8ae6efcf90f34b35912d6749c9d125cd1 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 8 Mar 2026 05:44:53 +0000 Subject: [PATCH] chore: add unit tests for certificate handler, logs websocket upgrader, config loading, and mail service --- .../api/handlers/certificate_handler_test.go | 38 +++++++++++++++++++ backend/internal/api/handlers/logs_ws_test.go | 37 ++++++++++++++++++ backend/internal/config/config_test.go | 18 +++++++++ .../internal/services/mail_service_test.go | 38 +++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index 46d9c905..4fad16d2 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -17,6 +17,8 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" @@ -516,6 +518,42 @@ func generateSelfSignedCertPEM() (certPEM, keyPEM string, err error) { // Note: mockCertificateService removed — helper tests now use real service instances or testify mocks inlined where required. +// TestCertificateHandler_Upload_WithNotificationService verifies that the notification +// path is exercised when a non-nil NotificationService is provided. +func TestCertificateHandler_Upload_WithNotificationService(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.Setting{}, &models.NotificationProvider{})) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(mockAuthMiddleware()) + + tmpDir := t.TempDir() + svc := services.NewCertificateService(tmpDir, db) + ns := services.NewNotificationService(db, nil) + h := NewCertificateHandler(svc, nil, ns) + r.POST("/api/certificates", h.Upload) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("name", "cert-with-ns") + certPEM, keyPEM, err := generateSelfSignedCertPEM() + require.NoError(t, err) + part, _ := writer.CreateFormFile("certificate_file", "cert.pem") + _, _ = part.Write([]byte(certPEM)) + part2, _ := writer.CreateFormFile("key_file", "key.pem") + _, _ = part2.Write([]byte(keyPEM)) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) +} + // Test Delete with invalid ID format func TestDeleteCertificate_InvalidID(t *testing.T) { db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) diff --git a/backend/internal/api/handlers/logs_ws_test.go b/backend/internal/api/handlers/logs_ws_test.go index 7659979d..06034712 100644 --- a/backend/internal/api/handlers/logs_ws_test.go +++ b/backend/internal/api/handlers/logs_ws_test.go @@ -33,6 +33,43 @@ func waitFor(t *testing.T, timeout time.Duration, condition func() bool) { t.Fatalf("condition not met within %s", timeout) } +func TestUpgraderCheckOrigin(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + origin string + host string + xForwardedHost string + want bool + }{ + {"empty origin allows request", "", "example.com", "", true}, + {"invalid URL origin rejects", "://bad-url", "example.com", "", false}, + {"matching host allows", "http://example.com", "example.com", "", true}, + {"non-matching host rejects", "http://evil.com", "example.com", "", false}, + {"X-Forwarded-Host matching allows", "http://proxy.example.com", "backend.internal", "proxy.example.com", true}, + {"X-Forwarded-Host non-matching rejects", "http://evil.com", "backend.internal", "proxy.example.com", false}, + {"origin with port matching", "http://example.com:8080", "example.com:8080", "", true}, + {"origin with port non-matching", "http://example.com:9090", "example.com:8080", "", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequest(http.MethodGet, "/ws", http.NoBody) + if tc.origin != "" { + req.Header.Set("Origin", tc.origin) + } + req.Host = tc.host + if tc.xForwardedHost != "" { + req.Header.Set("X-Forwarded-Host", tc.xForwardedHost) + } + got := upgrader.CheckOrigin(req) + assert.Equal(t, tc.want, got, "origin=%q host=%q xfh=%q", tc.origin, tc.host, tc.xForwardedHost) + }) + } +} + func TestLogsWebSocketHandler_DeprecatedWrapperUpgradeFailure(t *testing.T) { gin.SetMode(gin.TestMode) charonlogger.Init(false, io.Discard) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 98597da7..3b2f9003 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -152,6 +152,24 @@ func TestGetEnvIntAny(t *testing.T) { }) } +func TestLoad_JWTSecretFallbackGeneration(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db")) + t.Setenv("CHARON_CADDY_CONFIG_DIR", filepath.Join(tempDir, "caddy")) + t.Setenv("CHARON_IMPORT_DIR", filepath.Join(tempDir, "imports")) + + // Clear both JWT secret env vars to trigger fallback generation + t.Setenv("CHARON_JWT_SECRET", "") + t.Setenv("CPM_JWT_SECRET", "") + + cfg, err := Load() + require.NoError(t, err) + + // Fallback generates 32 random bytes → 64-char hex string + assert.NotEmpty(t, cfg.JWTSecret) + assert.Len(t, cfg.JWTSecret, 64) +} + func TestLoad_SecurityConfig(t *testing.T) { tempDir := t.TempDir() t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db")) diff --git a/backend/internal/services/mail_service_test.go b/backend/internal/services/mail_service_test.go index d3b96a51..60dc027b 100644 --- a/backend/internal/services/mail_service_test.go +++ b/backend/internal/services/mail_service_test.go @@ -1092,6 +1092,44 @@ func TestMailService_SendEmail_SSLSuccess(t *testing.T) { require.NoError(t, err) } +func TestMailService_SendEmail_ContextCancelled(t *testing.T) { + t.Parallel() + + db := setupMailTestDB(t) + svc := NewMailService(db) + require.NoError(t, svc.SaveSMTPConfig(&SMTPConfig{ + Host: "127.0.0.1", + Port: 2525, + FromAddress: "sender@example.com", + Encryption: "none", + })) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + err := svc.SendEmail(ctx, []string{"recipient@example.com"}, "Test Subject", "

Body

") + require.Error(t, err) + assert.Contains(t, err.Error(), "context cancelled") +} + +func TestSanitizeAndNormalizeHTMLBody_EmptyInput(t *testing.T) { + t.Parallel() + assert.Equal(t, "", sanitizeAndNormalizeHTMLBody("")) +} + +func TestSanitizeAndNormalizeHTMLBody_SingleLine(t *testing.T) { + t.Parallel() + result := sanitizeAndNormalizeHTMLBody("Hello World") + assert.Equal(t, "

Hello World

", result) +} + +func TestSanitizeAndNormalizeHTMLBody_HTMLEscaping(t *testing.T) { + t.Parallel() + result := sanitizeAndNormalizeHTMLBody("") + assert.NotContains(t, result, "