diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go index 43240ce9..bd2e1aeb 100644 --- a/backend/internal/api/handlers/certificate_handler_test.go +++ b/backend/internal/api/handlers/certificate_handler_test.go @@ -399,6 +399,45 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) { } } +func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T) { + db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open db: %v", err) + } + if err = db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil { + t.Fatalf("failed to migrate: %v", err) + } + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(mockAuthMiddleware()) + svc := services.NewCertificateService("/tmp", db) + h := NewCertificateHandler(svc, nil, nil) + r.POST("/api/certificates", h.Upload) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + _ = writer.WriteField("name", "testcert") + part, createErr := writer.CreateFormFile("certificate_file", "cert.pem") + if createErr != nil { + t.Fatalf("failed to create form file: %v", createErr) + } + _, _ = part.Write([]byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----")) + _ = writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 Bad Request, got %d, body=%s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "key_file") { + t.Fatalf("expected error message about key_file, got: %s", w.Body.String()) + } +} + // Test Upload handler success path using a mock CertificateService func TestCertificateHandler_Upload_Success(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/crowdsec_wave3_test.go b/backend/internal/api/handlers/crowdsec_wave3_test.go new file mode 100644 index 00000000..4d719f9c --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_wave3_test.go @@ -0,0 +1,87 @@ +package handlers + +import ( + "bytes" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveAcquisitionConfigPath_Validation(t *testing.T) { + t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", "") + resolved, err := resolveAcquisitionConfigPath() + require.NoError(t, err) + require.Equal(t, "/etc/crowdsec/acquis.yaml", resolved) + + t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", "relative/acquis.yaml") + _, err = resolveAcquisitionConfigPath() + require.Error(t, err) + + t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", "/tmp/../etc/acquis.yaml") + _, err = resolveAcquisitionConfigPath() + require.Error(t, err) + + t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", "/tmp/acquis.yaml") + resolved, err = resolveAcquisitionConfigPath() + require.NoError(t, err) + require.Equal(t, "/tmp/acquis.yaml", resolved) +} + +func TestReadAcquisitionConfig_ErrorsAndSuccess(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "acquis.yaml") + require.NoError(t, os.WriteFile(path, []byte("source: file\n"), 0o600)) + + content, err := readAcquisitionConfig(path) + require.NoError(t, err) + assert.Contains(t, string(content), "source: file") + + _, err = readAcquisitionConfig(filepath.Join(tmp, "missing.yaml")) + require.Error(t, err) +} + +func TestCrowdsec_AcquisitionEndpoints_InvalidConfiguredPath(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", "relative/path.yaml") + + h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + wGet := httptest.NewRecorder() + reqGet := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/acquisition", http.NoBody) + r.ServeHTTP(wGet, reqGet) + require.Equal(t, http.StatusInternalServerError, wGet.Code) + + wPut := httptest.NewRecorder() + reqPut := httptest.NewRequest(http.MethodPut, "/api/v1/admin/crowdsec/acquisition", bytes.NewBufferString(`{"content":"source: file"}`)) + reqPut.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wPut, reqPut) + require.Equal(t, http.StatusInternalServerError, wPut.Code) +} + +func TestCrowdsec_GetBouncerKey_NotConfigured(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CROWDSEC_API_KEY", "") + t.Setenv("CROWDSEC_BOUNCER_API_KEY", "") + t.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CPM_SECURITY_CROWDSEC_API_KEY", "") + + h := newTestCrowdsecHandler(t, OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir()) + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/bouncer/key", http.NoBody) + r.ServeHTTP(w, req) + require.Equal(t, http.StatusNotFound, w.Code) +} diff --git a/backend/internal/api/handlers/crowdsec_wave5_test.go b/backend/internal/api/handlers/crowdsec_wave5_test.go new file mode 100644 index 00000000..b71df08e --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_wave5_test.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCrowdsecWave5_ResolveAcquisitionConfigPath_RelativeRejected(t *testing.T) { + t.Setenv("CHARON_CROWDSEC_ACQUIS_PATH", "relative/acquis.yaml") + _, err := resolveAcquisitionConfigPath() + require.Error(t, err) + require.Contains(t, err.Error(), "must be absolute") +} + +func TestCrowdsecWave5_ReadAcquisitionConfig_InvalidFilenameBranch(t *testing.T) { + _, err := readAcquisitionConfig("/") + require.Error(t, err) + require.Contains(t, err.Error(), "filename is invalid") +} + +func TestCrowdsecWave5_GetLAPIDecisions_Unauthorized(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { + validateCrowdsecLAPIBaseURLFunc = original + }) + + require.NoError(t, db.Create(&models.SecurityConfig{UUID: "default", CrowdSecAPIURL: server.URL}).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir) + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/lapi", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Contains(t, w.Body.String(), "authentication failed") +} + +func TestCrowdsecWave5_GetLAPIDecisions_NonJSONContentTypeFallsBack(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + t.Cleanup(server.Close) + + original := validateCrowdsecLAPIBaseURLFunc + validateCrowdsecLAPIBaseURLFunc = func(raw string) (*url.URL, error) { + return url.Parse(raw) + } + t.Cleanup(func() { + validateCrowdsecLAPIBaseURLFunc = original + }) + + require.NoError(t, db.Create(&models.SecurityConfig{UUID: "default", CrowdSecAPIURL: server.URL}).Error) + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir) + h.CmdExec = &mockCmdExecutor{output: []byte("[]"), err: nil} + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/lapi", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + require.Contains(t, w.Body.String(), "decisions") +} + +func TestCrowdsecWave5_GetBouncerInfo_And_GetBouncerKey_FileSource(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CROWDSEC_BOUNCER_API_KEY", "") + t.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CPM_SECURITY_CROWDSEC_API_KEY", "") + db := setupCrowdDB(t) + tmpDir := t.TempDir() + + h := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir) + keyPath := h.bouncerKeyPath() + require.NoError(t, os.MkdirAll(filepath.Dir(keyPath), 0o750)) + require.NoError(t, os.WriteFile(keyPath, []byte("abcdefghijklmnop1234567890"), 0o600)) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + wInfo := httptest.NewRecorder() + reqInfo := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/bouncer", http.NoBody) + r.ServeHTTP(wInfo, reqInfo) + require.Equal(t, http.StatusOK, wInfo.Code) + require.Contains(t, wInfo.Body.String(), "file") + + wKey := httptest.NewRecorder() + reqKey := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/bouncer/key", http.NoBody) + r.ServeHTTP(wKey, reqKey) + require.Equal(t, http.StatusOK, wKey.Code) + require.Contains(t, wKey.Body.String(), "\"source\":\"file\"") +} diff --git a/backend/internal/api/handlers/crowdsec_wave6_test.go b/backend/internal/api/handlers/crowdsec_wave6_test.go new file mode 100644 index 00000000..48571053 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_wave6_test.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestCrowdsecWave6_BouncerKeyPath_UsesEnvFallback(t *testing.T) { + t.Setenv("CHARON_CROWDSEC_BOUNCER_KEY_PATH", "/tmp/test-bouncer-key") + h := &CrowdsecHandler{} + require.Equal(t, "/tmp/test-bouncer-key", h.bouncerKeyPath()) +} + +func TestCrowdsecWave6_GetBouncerInfo_NoneSource(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CROWDSEC_API_KEY", "") + t.Setenv("CROWDSEC_BOUNCER_API_KEY", "") + t.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CPM_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CHARON_CROWDSEC_BOUNCER_KEY_PATH", "/tmp/non-existent-wave6-key") + + h := &CrowdsecHandler{CmdExec: &mockCmdExecutor{output: []byte(`[]`)}} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/bouncer", nil) + + h.GetBouncerInfo(c) + + require.Equal(t, http.StatusOK, w.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + require.Equal(t, "none", payload["key_source"]) +} + +func TestCrowdsecWave6_GetKeyStatus_NoKeyConfiguredMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("CROWDSEC_API_KEY", "") + t.Setenv("CROWDSEC_BOUNCER_API_KEY", "") + t.Setenv("CERBERUS_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CHARON_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CPM_SECURITY_CROWDSEC_API_KEY", "") + t.Setenv("CHARON_CROWDSEC_BOUNCER_KEY_PATH", "/tmp/non-existent-wave6-key") + + h := &CrowdsecHandler{} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/key-status", nil) + + h.GetKeyStatus(c) + + require.Equal(t, http.StatusOK, w.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + require.Equal(t, "none", payload["key_source"]) + require.Equal(t, false, payload["valid"]) + require.Contains(t, payload["message"], "No CrowdSec API key configured") +} diff --git a/backend/internal/api/handlers/crowdsec_wave7_test.go b/backend/internal/api/handlers/crowdsec_wave7_test.go new file mode 100644 index 00000000..3211de9c --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_wave7_test.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestCrowdsecWave7_ReadAcquisitionConfig_ReadErrorOnDirectory(t *testing.T) { + tmpDir := t.TempDir() + acqDir := filepath.Join(tmpDir, "acq") + require.NoError(t, os.MkdirAll(acqDir, 0o750)) + + _, err := readAcquisitionConfig(acqDir) + require.Error(t, err) + require.Contains(t, err.Error(), "read acquisition config") +} + +func TestCrowdsecWave7_Start_CreateSecurityConfigFailsOnReadOnlyDB(t *testing.T) { + gin.SetMode(gin.TestMode) + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "crowdsec-readonly.db") + + rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, rwDB.AutoMigrate(&models.SecurityConfig{}, &models.Setting{})) + + sqlDB, err := rwDB.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + roDB, err := gorm.Open(sqlite.Open("file:"+dbPath+"?mode=ro"), &gorm.Config{}) + require.NoError(t, err) + + h := newTestCrowdsecHandler(t, roDB, &fakeExec{}, "/bin/false", t.TempDir()) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil) + + h.Start(c) + + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "Failed to persist configuration") +} + +func TestCrowdsecWave7_EnsureBouncerRegistration_InvalidFileKeyReRegisters(t *testing.T) { + tmpDir := t.TempDir() + keyPath := tmpDir + "/bouncer_key" + require.NoError(t, saveKeyToFile(keyPath, "invalid-file-key")) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer server.Close() + + db := setupCrowdDB(t) + handler := newTestCrowdsecHandler(t, db, &fakeExec{}, "/bin/false", tmpDir) + t.Setenv("CHARON_CROWDSEC_BOUNCER_KEY_PATH", keyPath) + + cfg := models.SecurityConfig{ + UUID: uuid.New().String(), + Name: "default", + CrowdSecAPIURL: server.URL, + } + require.NoError(t, db.Create(&cfg).Error) + + mockCmdExec := new(MockCommandExecutor) + mockCmdExec.On("Execute", mock.Anything, "cscli", mock.MatchedBy(func(args []string) bool { + return len(args) >= 2 && args[0] == "bouncers" && args[1] == "delete" + })).Return([]byte("deleted"), nil) + mockCmdExec.On("Execute", mock.Anything, "cscli", mock.MatchedBy(func(args []string) bool { + return len(args) >= 2 && args[0] == "bouncers" && args[1] == "add" + })).Return([]byte("new-file-key-1234567890"), nil) + handler.CmdExec = mockCmdExec + + key, err := handler.ensureBouncerRegistration(context.Background()) + require.NoError(t, err) + require.Equal(t, "new-file-key-1234567890", key) + require.Equal(t, "new-file-key-1234567890", readKeyFromFile(keyPath)) + mockCmdExec.AssertExpectations(t) +} diff --git a/backend/internal/api/handlers/import_handler_coverage_test.go b/backend/internal/api/handlers/import_handler_coverage_test.go index b59a7783..42881d79 100644 --- a/backend/internal/api/handlers/import_handler_coverage_test.go +++ b/backend/internal/api/handlers/import_handler_coverage_test.go @@ -5,17 +5,56 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/Wikid82/charon/backend/internal/caddy" + "github.com/Wikid82/charon/backend/internal/models" ) +type importCoverageProxyHostSvcStub struct{} + +func (importCoverageProxyHostSvcStub) Create(host *models.ProxyHost) error { return nil } +func (importCoverageProxyHostSvcStub) Update(host *models.ProxyHost) error { return nil } +func (importCoverageProxyHostSvcStub) List() ([]models.ProxyHost, error) { + return []models.ProxyHost{}, nil +} + +func setupReadOnlyImportDB(t *testing.T) *gorm.DB { + t.Helper() + + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "import_ro.db") + + rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, rwDB.AutoMigrate(&models.ImportSession{})) + sqlDB, err := rwDB.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + require.NoError(t, os.Chmod(dbPath, 0o400)) + + roDB, err := gorm.Open(sqlite.Open("file:"+dbPath+"?mode=ro"), &gorm.Config{}) + require.NoError(t, err) + + t.Cleanup(func() { + if roSQLDB, dbErr := roDB.DB(); dbErr == nil { + _ = roSQLDB.Close() + } + }) + + return roDB +} + func setupImportCoverageTestDB(t *testing.T) *gorm.DB { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) if err != nil { @@ -186,3 +225,292 @@ func TestUploadMulti_NoSitesParsed(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "no sites parsed") } + +func TestUpload_ImportsDetectedNoImportableHosts(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupImportCoverageTestDB(t) + mockSvc := new(MockImporterService) + mockSvc.On("NormalizeCaddyfile", mock.AnythingOfType("string")).Return("import sites/*.caddy # include\n", nil) + mockSvc.On("ImportFile", mock.AnythingOfType("string")).Return(&caddy.ImportResult{ + Hosts: []caddy.ParsedHost{}, + }, nil) + + tmpImport := t.TempDir() + h := NewImportHandler(db, "caddy", tmpImport, "") + h.importerservice = mockSvc + + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + r.POST("/upload", h.Upload) + + req := map[string]interface{}{ + "filename": "Caddyfile", + "content": "import sites/*.caddy # include\n", + } + body, _ := json.Marshal(req) + request, _ := http.NewRequest("POST", "/upload", bytes.NewBuffer(body)) + request.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, request) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "imports") + mockSvc.AssertExpectations(t) +} + +func TestUploadMulti_RequiresMainCaddyfile(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupImportCoverageTestDB(t) + h := NewImportHandler(db, "caddy", t.TempDir(), "") + + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + r.POST("/upload-multi", h.UploadMulti) + + req := map[string]interface{}{ + "files": []interface{}{ + map[string]string{"filename": "sites/site1.caddy", "content": "example.com { reverse_proxy localhost:8080 }"}, + }, + } + body, _ := json.Marshal(req) + request, _ := http.NewRequest("POST", "/upload-multi", bytes.NewBuffer(body)) + request.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, request) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "must include a main Caddyfile") +} + +func TestUploadMulti_RejectsEmptyFileContent(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupImportCoverageTestDB(t) + h := NewImportHandler(db, "caddy", t.TempDir(), "") + + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + r.POST("/upload-multi", h.UploadMulti) + + req := map[string]interface{}{ + "files": []interface{}{ + map[string]string{"filename": "Caddyfile", "content": " "}, + }, + } + body, _ := json.Marshal(req) + request, _ := http.NewRequest("POST", "/upload-multi", bytes.NewBuffer(body)) + request.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, request) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "is empty") +} + +func TestCommitAndCancel_InvalidSessionUUID(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupImportCoverageTestDB(t) + tmpImport := t.TempDir() + h := NewImportHandler(db, "caddy", tmpImport, "") + + r := gin.New() + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + h.RegisterRoutes(r.Group("/api/v1")) + + commitBody := map[string]interface{}{"session_uuid": ".", "resolutions": map[string]string{}} + commitBytes, _ := json.Marshal(commitBody) + wCommit := httptest.NewRecorder() + reqCommit, _ := http.NewRequest(http.MethodPost, "/api/v1/import/commit", bytes.NewBuffer(commitBytes)) + reqCommit.Header.Set("Content-Type", "application/json") + r.ServeHTTP(wCommit, reqCommit) + assert.Equal(t, http.StatusBadRequest, wCommit.Code) + + wCancel := httptest.NewRecorder() + reqCancel, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid=.", http.NoBody) + r.ServeHTTP(wCancel, reqCancel) + assert.Equal(t, http.StatusBadRequest, wCancel.Code) +} + +func TestCancel_RemovesTransientUpload(t *testing.T) { + gin.SetMode(gin.TestMode) + + db := setupImportCoverageTestDB(t) + tmpImport := t.TempDir() + h := NewImportHandler(db, "caddy", tmpImport, "") + + uploadsDir := filepath.Join(tmpImport, "uploads") + require.NoError(t, os.MkdirAll(uploadsDir, 0o750)) + sid := "test-sid" + uploadPath := filepath.Join(uploadsDir, sid+".caddyfile") + require.NoError(t, os.WriteFile(uploadPath, []byte("example.com { reverse_proxy localhost:8080 }"), 0o600)) + + r := gin.New() + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + h.RegisterRoutes(r.Group("/api/v1")) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid="+sid, http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + _, statErr := os.Stat(uploadPath) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestUpload_ReadOnlyDBRespondsWithPermissionError(t *testing.T) { + gin.SetMode(gin.TestMode) + + roDB := setupReadOnlyImportDB(t) + mockSvc := new(MockImporterService) + mockSvc.On("NormalizeCaddyfile", mock.AnythingOfType("string")).Return("example.com { reverse_proxy localhost:8080 }", nil) + mockSvc.On("ImportFile", mock.AnythingOfType("string")).Return(&caddy.ImportResult{ + Hosts: []caddy.ParsedHost{{DomainNames: "example.com", ForwardHost: "localhost", ForwardPort: 8080}}, + }, nil) + + h := NewImportHandler(roDB, "caddy", t.TempDir(), "") + h.importerservice = mockSvc + + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + r.POST("/upload", h.Upload) + + body, _ := json.Marshal(map[string]any{ + "filename": "Caddyfile", + "content": "example.com { reverse_proxy localhost:8080 }", + }) + req, _ := http.NewRequest(http.MethodPost, "/upload", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "permissions_db_readonly") +} + +func TestUploadMulti_ReadOnlyDBRespondsWithPermissionError(t *testing.T) { + gin.SetMode(gin.TestMode) + + roDB := setupReadOnlyImportDB(t) + mockSvc := new(MockImporterService) + mockSvc.On("ImportFile", mock.AnythingOfType("string")).Return(&caddy.ImportResult{ + Hosts: []caddy.ParsedHost{{DomainNames: "multi.example.com", ForwardHost: "localhost", ForwardPort: 8081}}, + }, nil) + + h := NewImportHandler(roDB, "caddy", t.TempDir(), "") + h.importerservice = mockSvc + + w := httptest.NewRecorder() + _, r := gin.CreateTestContext(w) + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + r.POST("/upload-multi", h.UploadMulti) + + body, _ := json.Marshal(map[string]any{ + "files": []map[string]string{{ + "filename": "Caddyfile", + "content": "multi.example.com { reverse_proxy localhost:8081 }", + }}, + }) + req, _ := http.NewRequest(http.MethodPost, "/upload-multi", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "permissions_db_readonly") +} + +func TestCommit_ReadOnlyDBSaveRespondsWithPermissionError(t *testing.T) { + gin.SetMode(gin.TestMode) + + roDB := setupReadOnlyImportDB(t) + mockSvc := new(MockImporterService) + mockSvc.On("ImportFile", mock.AnythingOfType("string")).Return(&caddy.ImportResult{ + Hosts: []caddy.ParsedHost{{DomainNames: "commit.example.com", ForwardHost: "localhost", ForwardPort: 8080}}, + }, nil) + + importDir := t.TempDir() + uploadsDir := filepath.Join(importDir, "uploads") + require.NoError(t, os.MkdirAll(uploadsDir, 0o750)) + sid := "readonly-commit-session" + require.NoError(t, os.WriteFile(filepath.Join(uploadsDir, sid+".caddyfile"), []byte("commit.example.com { reverse_proxy localhost:8080 }"), 0o600)) + + h := NewImportHandlerWithService(roDB, importCoverageProxyHostSvcStub{}, "caddy", importDir, "", nil) + h.importerservice = mockSvc + + r := gin.New() + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + r.POST("/commit", h.Commit) + + body, _ := json.Marshal(map[string]any{"session_uuid": sid, "resolutions": map[string]string{}}) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/commit", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "permissions_db_readonly") +} + +func TestCancel_ReadOnlyDBSaveRespondsWithPermissionError(t *testing.T) { + gin.SetMode(gin.TestMode) + + tmp := t.TempDir() + dbPath := filepath.Join(tmp, "cancel_ro.db") + + rwDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, rwDB.AutoMigrate(&models.ImportSession{})) + require.NoError(t, rwDB.Create(&models.ImportSession{UUID: "readonly-cancel", Status: "pending"}).Error) + rwSQLDB, err := rwDB.DB() + require.NoError(t, err) + require.NoError(t, rwSQLDB.Close()) + require.NoError(t, os.Chmod(dbPath, 0o400)) + + roDB, err := gorm.Open(sqlite.Open("file:"+dbPath+"?mode=ro"), &gorm.Config{}) + require.NoError(t, err) + if roSQLDB, dbErr := roDB.DB(); dbErr == nil { + t.Cleanup(func() { _ = roSQLDB.Close() }) + } + + h := NewImportHandler(roDB, "caddy", t.TempDir(), "") + + r := gin.New() + r.Use(func(c *gin.Context) { + setAdminContext(c) + c.Next() + }) + r.DELETE("/cancel", h.Cancel) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/cancel?session_uuid=readonly-cancel", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), "permissions_db_readonly") +} diff --git a/backend/internal/api/handlers/import_handler_test.go b/backend/internal/api/handlers/import_handler_test.go index e46b8fb0..3e8b5050 100644 --- a/backend/internal/api/handlers/import_handler_test.go +++ b/backend/internal/api/handlers/import_handler_test.go @@ -14,6 +14,7 @@ import ( "github.com/Wikid82/charon/backend/internal/caddy" "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/testutil" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -870,6 +871,117 @@ func TestImportHandler_Commit_InvalidSessionUUID_BranchCoverage(t *testing.T) { }) } +func TestImportHandler_Upload_NoImportableHosts_WithImportsDetected(t *testing.T) { + testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { + handler, _, mockImport := setupTestHandler(t, tx) + + mockImport.importResult = &caddy.ImportResult{ + Hosts: []caddy.ParsedHost{{ + DomainNames: "file.example.com", + Warnings: []string{"file_server detected"}, + }}, + } + handler.importerservice = &mockImporterAdapter{mockImport} + + reqBody := map[string]string{ + "content": "import sites/*.caddyfile", + "filename": "Caddyfile", + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/import/upload", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + gin.SetMode(gin.TestMode) + router := gin.New() + addAdminMiddleware(router) + handler.RegisterRoutes(router.Group("/api/v1")) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "imports detected") + }) +} + +func TestImportHandler_Upload_NoImportableHosts_NoImportsNoFileServer(t *testing.T) { + testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { + handler, _, mockImport := setupTestHandler(t, tx) + + mockImport.importResult = &caddy.ImportResult{ + Hosts: []caddy.ParsedHost{{ + DomainNames: "noop.example.com", + }}, + } + handler.importerservice = &mockImporterAdapter{mockImport} + + reqBody := map[string]string{ + "content": "noop.example.com { respond \"ok\" }", + "filename": "Caddyfile", + } + body, _ := json.Marshal(reqBody) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/import/upload", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + gin.SetMode(gin.TestMode) + router := gin.New() + addAdminMiddleware(router) + handler.RegisterRoutes(router.Group("/api/v1")) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "no sites found in uploaded Caddyfile") + }) +} + +func TestImportHandler_Commit_OverwriteAndRenameFlows(t *testing.T) { + testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { + handler, _, mockImport := setupTestHandler(t, tx) + handler.proxyHostSvc = services.NewProxyHostService(tx) + + mockImport.importResult = &caddy.ImportResult{ + Hosts: []caddy.ParsedHost{ + {DomainNames: "rename.example.com", ForwardScheme: "http", ForwardHost: "rename-host", ForwardPort: 9000}, + }, + } + handler.importerservice = &mockImporterAdapter{mockImport} + + uploadPath := filepath.Join(handler.importDir, "uploads", "overwrite-rename.caddyfile") + require.NoError(t, os.MkdirAll(filepath.Dir(uploadPath), 0o700)) + require.NoError(t, os.WriteFile(uploadPath, []byte("placeholder"), 0o600)) + + commitBody := map[string]any{ + "session_uuid": "overwrite-rename", + "resolutions": map[string]string{ + "rename.example.com": "rename", + }, + "names": map[string]string{ + "rename.example.com": "Renamed Host", + }, + } + body, _ := json.Marshal(commitBody) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/import/commit", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + gin.SetMode(gin.TestMode) + router := gin.New() + addAdminMiddleware(router) + handler.RegisterRoutes(router.Group("/api/v1")) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), "\"created\":1") + + var renamed models.ProxyHost + require.NoError(t, tx.Where("domain_names = ?", "rename.example.com-imported").First(&renamed).Error) + assert.Equal(t, "Renamed Host", renamed.Name) + }) +} + func TestImportHandler_Cancel_ValidationAndNotFound_BranchCoverage(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, _ := setupTestHandler(t, tx) diff --git a/backend/internal/api/handlers/notification_template_handler_test.go b/backend/internal/api/handlers/notification_template_handler_test.go index acdfa469..7f9cd6ce 100644 --- a/backend/internal/api/handlers/notification_template_handler_test.go +++ b/backend/internal/api/handlers/notification_template_handler_test.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -149,3 +150,150 @@ func TestNotificationTemplateHandler_Preview_InvalidJSON(t *testing.T) { r.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) } + +func TestNotificationTemplateHandler_AdminRequired(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) + + r := gin.New() + r.POST("/api/templates", h.Create) + r.PUT("/api/templates/:id", h.Update) + r.DELETE("/api/templates/:id", h.Delete) + + createReq := httptest.NewRequest(http.MethodPost, "/api/templates", strings.NewReader(`{"name":"x","config":"{}"}`)) + createReq.Header.Set("Content-Type", "application/json") + createW := httptest.NewRecorder() + r.ServeHTTP(createW, createReq) + require.Equal(t, http.StatusForbidden, createW.Code) + + updateReq := httptest.NewRequest(http.MethodPut, "/api/templates/test-id", strings.NewReader(`{"name":"x","config":"{}"}`)) + updateReq.Header.Set("Content-Type", "application/json") + updateW := httptest.NewRecorder() + r.ServeHTTP(updateW, updateReq) + require.Equal(t, http.StatusForbidden, updateW.Code) + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/templates/test-id", http.NoBody) + deleteW := httptest.NewRecorder() + r.ServeHTTP(deleteW, deleteReq) + require.Equal(t, http.StatusForbidden, deleteW.Code) +} + +func TestNotificationTemplateHandler_List_DBError(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) + + r := gin.New() + r.GET("/api/templates", h.List) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + req := httptest.NewRequest(http.MethodGet, "/api/templates", http.NoBody) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusInternalServerError, w.Code) +} + +func TestNotificationTemplateHandler_WriteOps_DBError(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + r.POST("/api/templates", h.Create) + r.PUT("/api/templates/:id", h.Update) + r.DELETE("/api/templates/:id", h.Delete) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + createReq := httptest.NewRequest(http.MethodPost, "/api/templates", strings.NewReader(`{"name":"x","config":"{}"}`)) + createReq.Header.Set("Content-Type", "application/json") + createW := httptest.NewRecorder() + r.ServeHTTP(createW, createReq) + require.Equal(t, http.StatusInternalServerError, createW.Code) + + updateReq := httptest.NewRequest(http.MethodPut, "/api/templates/test-id", strings.NewReader(`{"id":"test-id","name":"x","config":"{}"}`)) + updateReq.Header.Set("Content-Type", "application/json") + updateW := httptest.NewRecorder() + r.ServeHTTP(updateW, updateReq) + require.Equal(t, http.StatusInternalServerError, updateW.Code) + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/templates/test-id", http.NoBody) + deleteW := httptest.NewRecorder() + r.ServeHTTP(deleteW, deleteReq) + require.Equal(t, http.StatusInternalServerError, deleteW.Code) +} + +func TestNotificationTemplateHandler_WriteOps_PermissionErrorResponse(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.NotificationTemplate{})) + + createHook := "test_notification_template_permission_create" + updateHook := "test_notification_template_permission_update" + deleteHook := "test_notification_template_permission_delete" + + require.NoError(t, db.Callback().Create().Before("gorm:create").Register(createHook, func(tx *gorm.DB) { + _ = tx.AddError(fmt.Errorf("permission denied")) + })) + require.NoError(t, db.Callback().Update().Before("gorm:update").Register(updateHook, func(tx *gorm.DB) { + _ = tx.AddError(fmt.Errorf("permission denied")) + })) + require.NoError(t, db.Callback().Delete().Before("gorm:delete").Register(deleteHook, func(tx *gorm.DB) { + _ = tx.AddError(fmt.Errorf("permission denied")) + })) + t.Cleanup(func() { + _ = db.Callback().Create().Remove(createHook) + _ = db.Callback().Update().Remove(updateHook) + _ = db.Callback().Delete().Remove(deleteHook) + }) + + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) + + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + r.POST("/api/templates", h.Create) + r.PUT("/api/templates/:id", h.Update) + r.DELETE("/api/templates/:id", h.Delete) + + createReq := httptest.NewRequest(http.MethodPost, "/api/templates", strings.NewReader(`{"name":"x","config":"{}"}`)) + createReq.Header.Set("Content-Type", "application/json") + createW := httptest.NewRecorder() + r.ServeHTTP(createW, createReq) + require.Equal(t, http.StatusInternalServerError, createW.Code) + require.Contains(t, createW.Body.String(), "permissions_write_denied") + + updateReq := httptest.NewRequest(http.MethodPut, "/api/templates/test-id", strings.NewReader(`{"id":"test-id","name":"x","config":"{}"}`)) + updateReq.Header.Set("Content-Type", "application/json") + updateW := httptest.NewRecorder() + r.ServeHTTP(updateW, updateReq) + require.Equal(t, http.StatusInternalServerError, updateW.Code) + require.Contains(t, updateW.Body.String(), "permissions_write_denied") + + deleteReq := httptest.NewRequest(http.MethodDelete, "/api/templates/test-id", http.NoBody) + deleteW := httptest.NewRecorder() + r.ServeHTTP(deleteW, deleteReq) + require.Equal(t, http.StatusInternalServerError, deleteW.Code) + require.Contains(t, deleteW.Body.String(), "permissions_write_denied") +} diff --git a/backend/internal/api/handlers/security_handler_coverage_test.go b/backend/internal/api/handlers/security_handler_coverage_test.go index ac871583..49b83837 100644 --- a/backend/internal/api/handlers/security_handler_coverage_test.go +++ b/backend/internal/api/handlers/security_handler_coverage_test.go @@ -16,6 +16,7 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" + "gorm.io/gorm" ) // Tests for UpdateConfig handler to improve coverage (currently 46%) @@ -772,3 +773,205 @@ func TestSecurityHandler_Enable_WithExactIPWhitelist(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) } + +func TestSecurityHandler_GetStatus_BackwardCompatibilityOverrides(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.Setting{}, &models.CaddyConfig{})) + + require.NoError(t, db.Create(&models.SecurityConfig{ + Name: "default", + Enabled: true, + WAFMode: "block", + RateLimitMode: "enabled", + CrowdSecMode: "local", + }).Error) + + seed := []models.Setting{ + {Key: "security.cerberus.enabled", Value: "false", Category: "security", Type: "bool"}, + {Key: "security.crowdsec.mode", Value: "external", Category: "security", Type: "string"}, + {Key: "security.waf.enabled", Value: "true", Category: "security", Type: "bool"}, + {Key: "security.rate_limit.enabled", Value: "true", Category: "security", Type: "bool"}, + {Key: "security.acl.enabled", Value: "true", Category: "security", Type: "bool"}, + } + for _, setting := range seed { + require.NoError(t, db.Create(&setting).Error) + } + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.GET("/security/status", handler.GetStatus) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/security/status", http.NoBody) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + + cerberus := resp["cerberus"].(map[string]any) + require.Equal(t, false, cerberus["enabled"]) + + crowdsec := resp["crowdsec"].(map[string]any) + require.Equal(t, "disabled", crowdsec["mode"]) + require.Equal(t, false, crowdsec["enabled"]) +} + +func TestSecurityHandler_AddWAFExclusion_InvalidExistingJSONStillAdds(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.SecurityAudit{})) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", WAFExclusions: "{"}).Error) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.POST("/security/waf/exclusions", handler.AddWAFExclusion) + + body := `{"rule_id":942100,"target":"ARGS:user","description":"test"}` + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/security/waf/exclusions", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) +} + +func TestSecurityHandler_ToggleSecurityModule_SnapshotSettingsError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.POST("/security/waf/enable", handler.EnableWAF) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/security/waf/enable", http.NoBody) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "Failed to update security module") +} + +func TestSecurityHandler_ToggleSecurityModule_SnapshotSecurityConfigError(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})) + require.NoError(t, db.Exec("DROP TABLE security_configs").Error) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Next() + }) + router.POST("/security/waf/enable", handler.EnableWAF) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/security/waf/enable", http.NoBody) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "Failed to update security module") +} + +func TestSecurityHandler_SnapshotAndRestoreHelpers(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.Setting{}, &models.SecurityConfig{})) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + require.NoError(t, db.Create(&models.Setting{Key: "k1", Value: "v1", Category: "security", Type: "string"}).Error) + + snapshots, err := handler.snapshotSettings([]string{"k1", "k1", "k2"}) + require.NoError(t, err) + require.Len(t, snapshots, 2) + require.True(t, snapshots["k1"].exists) + require.False(t, snapshots["k2"].exists) + + require.NoError(t, handler.restoreSettings(map[string]settingSnapshot{ + "k1": snapshots["k1"], + "k2": snapshots["k2"], + })) + + require.NoError(t, db.Exec("DROP TABLE settings").Error) + err = handler.restoreSettings(map[string]settingSnapshot{ + "k1": snapshots["k1"], + }) + require.Error(t, err) +} + +func TestSecurityHandler_DefaultSecurityConfigStateHelpers(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + + handler := NewSecurityHandler(config.SecurityConfig{}, db, nil) + + exists, enabled, err := handler.snapshotDefaultSecurityConfigState() + require.NoError(t, err) + require.False(t, exists) + require.False(t, enabled) + + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: true}).Error) + exists, enabled, err = handler.snapshotDefaultSecurityConfigState() + require.NoError(t, err) + require.True(t, exists) + require.True(t, enabled) + + require.NoError(t, handler.restoreDefaultSecurityConfigState(true, false)) + var cfg models.SecurityConfig + require.NoError(t, db.Where("name = ?", "default").First(&cfg).Error) + require.False(t, cfg.Enabled) + + require.NoError(t, handler.restoreDefaultSecurityConfigState(false, false)) + err = db.Where("name = ?", "default").First(&cfg).Error + require.ErrorIs(t, err, gorm.ErrRecordNotFound) +} + +func TestSecurityHandler_EnsureSecurityConfigEnabled_Helper(t *testing.T) { + handler := &SecurityHandler{db: nil} + err := handler.ensureSecurityConfigEnabled() + require.Error(t, err) + require.Contains(t, err.Error(), "database not configured") + + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{})) + require.NoError(t, db.Create(&models.SecurityConfig{Name: "default", Enabled: false}).Error) + + handler = NewSecurityHandler(config.SecurityConfig{}, db, nil) + require.NoError(t, handler.ensureSecurityConfigEnabled()) + + var cfg models.SecurityConfig + require.NoError(t, db.Where("name = ?", "default").First(&cfg).Error) + require.True(t, cfg.Enabled) +} + +func TestLatestConfigApplyState_Helper(t *testing.T) { + state := latestConfigApplyState(nil) + require.Equal(t, false, state["available"]) + + db := setupTestDB(t) + require.NoError(t, db.AutoMigrate(&models.CaddyConfig{})) + + state = latestConfigApplyState(db) + require.Equal(t, false, state["available"]) + + require.NoError(t, db.Create(&models.CaddyConfig{Success: true}).Error) + state = latestConfigApplyState(db) + require.Equal(t, true, state["available"]) + require.Equal(t, "applied", state["status"]) +} diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go index 94a92dc8..c389210b 100644 --- a/backend/internal/api/handlers/settings_handler_test.go +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -28,6 +28,14 @@ type mockCaddyConfigManager struct { calls int } +type mockCacheInvalidator struct { + calls int +} + +func (m *mockCacheInvalidator) InvalidateCache() { + m.calls++ +} + func (m *mockCaddyConfigManager) ApplyConfig(ctx context.Context) error { m.calls++ if m.applyFunc != nil { @@ -359,6 +367,132 @@ func TestSettingsHandler_UpdateSetting_SecurityKeyApplyFailureReturnsError(t *te assert.Equal(t, 1, mgr.calls) } +func TestSettingsHandler_UpdateSetting_NonAdminForbidden(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", "user") + c.Next() + }) + router.POST("/settings", handler.UpdateSetting) + + payload := map[string]string{"key": "security.waf.enabled", "value": "true"} + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestSettingsHandler_UpdateSetting_InvalidAdminWhitelist(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := newAdminRouter() + router.POST("/settings", handler.UpdateSetting) + + payload := map[string]string{ + "key": "security.admin_whitelist", + "value": "invalid-cidr-without-prefix", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid admin_whitelist") +} + +func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + mgr := &mockCaddyConfigManager{} + inv := &mockCacheInvalidator{} + handler := handlers.NewSettingsHandlerWithDeps(db, mgr, inv, nil, "") + router := newAdminRouter() + router.POST("/settings", handler.UpdateSetting) + + payload := map[string]string{ + "key": "security.rate_limit.enabled", + "value": "true", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, 1, inv.calls) + assert.Equal(t, 1, mgr.calls) +} + +func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := newAdminRouter() + router.PATCH("/config", handler.PatchConfig) + + payload := map[string]any{ + "security": map[string]any{ + "admin_whitelist": "bad-cidr", + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid admin_whitelist") +} + +func TestSettingsHandler_PatchConfig_ReloadFailureReturns500(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + mgr := &mockCaddyConfigManager{applyFunc: func(context.Context) error { + return fmt.Errorf("reload failed") + }} + inv := &mockCacheInvalidator{} + handler := handlers.NewSettingsHandlerWithDeps(db, mgr, inv, nil, "") + router := newAdminRouter() + router.PATCH("/config", handler.PatchConfig) + + payload := map[string]any{ + "security": map[string]any{ + "waf": map[string]any{"enabled": true}, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Equal(t, 1, inv.calls) + assert.Equal(t, 1, mgr.calls) + assert.Contains(t, w.Body.String(), "Failed to reload configuration") +} + func TestSettingsHandler_PatchConfig_SyncsAdminWhitelist(t *testing.T) { gin.SetMode(gin.TestMode) db := setupSettingsTestDB(t) diff --git a/backend/internal/api/handlers/settings_wave3_test.go b/backend/internal/api/handlers/settings_wave3_test.go new file mode 100644 index 00000000..ff07d9ae --- /dev/null +++ b/backend/internal/api/handlers/settings_wave3_test.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupSettingsWave3DB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityConfig{}, &models.Setting{})) + return db +} + +func TestSettingsHandler_EnsureSecurityConfigEnabledWithDB_Branches(t *testing.T) { + db := setupSettingsWave3DB(t) + h := &SettingsHandler{DB: db} + + // Record missing -> create enabled + require.NoError(t, h.ensureSecurityConfigEnabledWithDB(db)) + var cfg models.SecurityConfig + require.NoError(t, db.Where("name = ?", "default").First(&cfg).Error) + require.True(t, cfg.Enabled) + + // Record exists enabled=false -> update to true + require.NoError(t, db.Model(&cfg).Update("enabled", false).Error) + require.NoError(t, h.ensureSecurityConfigEnabledWithDB(db)) + require.NoError(t, db.Where("name = ?", "default").First(&cfg).Error) + require.True(t, cfg.Enabled) + + // Record exists enabled=true -> no-op success + require.NoError(t, h.ensureSecurityConfigEnabledWithDB(db)) +} + +func TestFlattenConfig_MixedTypes(t *testing.T) { + result := map[string]string{} + input := map[string]interface{}{ + "security": map[string]interface{}{ + "acl": map[string]interface{}{ + "enabled": true, + }, + "rate_limit": map[string]interface{}{ + "requests": 100, + }, + }, + "name": "charon", + } + + flattenConfig(input, "", result) + + require.Equal(t, "true", result["security.acl.enabled"]) + require.Equal(t, "100", result["security.rate_limit.requests"]) + require.Equal(t, "charon", result["name"]) +} + +func TestValidateAdminWhitelist_Strictness(t *testing.T) { + require.NoError(t, validateAdminWhitelist("")) + require.NoError(t, validateAdminWhitelist("192.0.2.0/24, 198.51.100.10/32")) + require.Error(t, validateAdminWhitelist("192.0.2.1")) +} diff --git a/backend/internal/api/handlers/settings_wave4_test.go b/backend/internal/api/handlers/settings_wave4_test.go new file mode 100644 index 00000000..e3cc9167 --- /dev/null +++ b/backend/internal/api/handlers/settings_wave4_test.go @@ -0,0 +1,200 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type wave4CaddyManager struct { + calls int + err error +} + +func (m *wave4CaddyManager) ApplyConfig(context.Context) error { + m.calls++ + return m.err +} + +type wave4CacheInvalidator struct { + calls int +} + +func (i *wave4CacheInvalidator) InvalidateCache() { + i.calls++ +} + +func registerCreatePermissionDeniedHook(t *testing.T, db *gorm.DB, name string, shouldFail func(*gorm.DB) bool) { + t.Helper() + require.NoError(t, db.Callback().Create().Before("gorm:create").Register(name, func(tx *gorm.DB) { + if shouldFail(tx) { + _ = tx.AddError(fmt.Errorf("permission denied")) + } + })) + t.Cleanup(func() { + _ = db.Callback().Create().Remove(name) + }) +} + +func settingKeyFromCreateCallback(tx *gorm.DB) string { + if tx == nil || tx.Statement == nil || tx.Statement.Dest == nil { + return "" + } + switch v := tx.Statement.Dest.(type) { + case *models.Setting: + return v.Key + case models.Setting: + return v.Key + default: + return "" + } +} + +func performUpdateSettingRequest(t *testing.T, h *SettingsHandler, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + g := gin.New() + g.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + g.POST("/settings", h.UpdateSetting) + + body, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + g.ServeHTTP(w, req) + return w +} + +func performPatchConfigRequest(t *testing.T, h *SettingsHandler, payload map[string]any) *httptest.ResponseRecorder { + t.Helper() + g := gin.New() + g.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + g.PATCH("/config", h.PatchConfig) + + body, err := json.Marshal(payload) + require.NoError(t, err) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + g.ServeHTTP(w, req) + return w +} + +func TestSettingsHandlerWave4_UpdateSetting_ACLPathsPermissionErrors(t *testing.T) { + t.Run("feature cerberus upsert permission denied", func(t *testing.T) { + db := setupSettingsWave3DB(t) + registerCreatePermissionDeniedHook(t, db, "wave4-deny-feature-cerberus", func(tx *gorm.DB) bool { + return settingKeyFromCreateCallback(tx) == "feature.cerberus.enabled" + }) + + h := NewSettingsHandler(db) + h.SecuritySvc = services.NewSecurityService(db) + h.DataRoot = "/app/data" + + w := performUpdateSettingRequest(t, h, map[string]any{ + "key": "security.acl.enabled", + "value": "true", + }) + + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "permissions_write_denied") + }) + +} + +func TestSettingsHandlerWave4_PatchConfig_SecurityReloadSuccessLogsPath(t *testing.T) { + db := setupSettingsWave3DB(t) + mgr := &wave4CaddyManager{} + inv := &wave4CacheInvalidator{} + + h := NewSettingsHandlerWithDeps(db, mgr, inv, nil, "") + w := performPatchConfigRequest(t, h, map[string]any{ + "security": map[string]any{ + "waf": map[string]any{"enabled": true}, + }, + }) + + require.Equal(t, http.StatusOK, w.Code) + require.Equal(t, 1, mgr.calls) + require.Equal(t, 1, inv.calls) +} + +func TestSettingsHandlerWave4_UpdateSetting_GenericSaveError(t *testing.T) { + db := setupSettingsWave3DB(t) + require.NoError(t, db.Callback().Create().Before("gorm:create").Register("wave4-generic-save-error", func(tx *gorm.DB) { + if settingKeyFromCreateCallback(tx) == "security.waf.enabled" { + _ = tx.AddError(fmt.Errorf("boom")) + } + })) + t.Cleanup(func() { + _ = db.Callback().Create().Remove("wave4-generic-save-error") + }) + + h := NewSettingsHandler(db) + h.SecuritySvc = services.NewSecurityService(db) + h.DataRoot = "/app/data" + + w := performUpdateSettingRequest(t, h, map[string]any{ + "key": "security.waf.enabled", + "value": "true", + }) + + require.Equal(t, http.StatusInternalServerError, w.Code) + require.Contains(t, w.Body.String(), "Failed to save setting") +} + +func TestSettingsHandlerWave4_PatchConfig_InvalidAdminWhitelistFromSync(t *testing.T) { + db := setupSettingsWave3DB(t) + h := NewSettingsHandler(db) + h.SecuritySvc = services.NewSecurityService(db) + h.DataRoot = "/app/data" + + w := performPatchConfigRequest(t, h, map[string]any{ + "security": map[string]any{ + "admin_whitelist": "10.10.10.10/", + }, + }) + + require.Equal(t, http.StatusBadRequest, w.Code) + require.Contains(t, w.Body.String(), "Invalid admin_whitelist") +} + +func TestSettingsHandlerWave4_TestPublicURL_BindError(t *testing.T) { + db := setupSettingsWave3DB(t) + h := NewSettingsHandler(db) + + g := gin.New() + g.Use(func(c *gin.Context) { + c.Set("role", "admin") + c.Set("userID", uint(1)) + c.Next() + }) + g.POST("/settings/test-public-url", h.TestPublicURL) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/settings/test-public-url", bytes.NewBufferString("{")) + req.Header.Set("Content-Type", "application/json") + g.ServeHTTP(w, req) + + require.Equal(t, http.StatusBadRequest, w.Code) +} diff --git a/backend/internal/api/handlers/system_permissions_handler_test.go b/backend/internal/api/handlers/system_permissions_handler_test.go index c433d358..de8e605e 100644 --- a/backend/internal/api/handlers/system_permissions_handler_test.go +++ b/backend/internal/api/handlers/system_permissions_handler_test.go @@ -10,16 +10,30 @@ import ( "path/filepath" "syscall" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/Wikid82/charon/backend/internal/config" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" "github.com/Wikid82/charon/backend/internal/util" + "gorm.io/driver/sqlite" + "gorm.io/gorm" ) type stubPermissionChecker struct{} +type fakeNoStatFileInfo struct{} + +func (fakeNoStatFileInfo) Name() string { return "fake" } +func (fakeNoStatFileInfo) Size() int64 { return 0 } +func (fakeNoStatFileInfo) Mode() os.FileMode { return 0 } +func (fakeNoStatFileInfo) ModTime() time.Time { return time.Time{} } +func (fakeNoStatFileInfo) IsDir() bool { return false } +func (fakeNoStatFileInfo) Sys() any { return nil } + func (stubPermissionChecker) Check(path, required string) util.PermissionCheck { return util.PermissionCheck{ Path: path, @@ -192,6 +206,12 @@ func TestSystemPermissionsHandler_PathHasSymlink(t *testing.T) { require.Error(t, err) } +func TestSystemPermissionsHandler_NewDefaultsCheckerToOSChecker(t *testing.T) { + h := NewSystemPermissionsHandler(config.Config{}, nil, nil) + require.NotNil(t, h) + require.NotNil(t, h.checker) +} + func TestSystemPermissionsHandler_RepairPermissions_DisabledWhenNotSingleContainer(t *testing.T) { gin.SetMode(gin.TestMode) @@ -289,6 +309,132 @@ func TestSystemPermissionsHandler_RepairPermissions_Success(t *testing.T) { require.NotEqual(t, "error", payload.Paths[0].Status) } +func TestSystemPermissionsHandler_RepairPermissions_NonAdmin(t *testing.T) { + gin.SetMode(gin.TestMode) + + h := NewSystemPermissionsHandler(config.Config{SingleContainer: true}, nil, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "user") + c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":["/tmp"]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.RepairPermissions(c) + + require.Equal(t, http.StatusForbidden, w.Code) +} + +func TestSystemPermissionsHandler_RepairPermissions_InvalidJSONWhenRoot(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("test requires root execution") + } + + gin.SetMode(gin.TestMode) + root := t.TempDir() + dataDir := filepath.Join(root, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o750)) + + h := NewSystemPermissionsHandler(config.Config{ + SingleContainer: true, + DatabasePath: filepath.Join(dataDir, "charon.db"), + ConfigRoot: dataDir, + CaddyLogDir: dataDir, + CrowdSecLogDir: dataDir, + }, nil, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.RepairPermissions(c) + + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSystemPermissionsHandler_DefaultPathsAndAllowlistRoots(t *testing.T) { + h := NewSystemPermissionsHandler(config.Config{ + DatabasePath: "/app/data/charon.db", + ConfigRoot: "/app/config", + CaddyLogDir: "/var/log/caddy", + CrowdSecLogDir: "/var/log/crowdsec", + PluginsDir: "/app/plugins", + }, nil, stubPermissionChecker{}) + + paths := h.defaultPaths() + require.Len(t, paths, 11) + require.Equal(t, "/app/data", paths[0].Path) + require.Equal(t, "/app/plugins", paths[len(paths)-1].Path) + + roots := h.allowlistRoots() + require.Equal(t, []string{"/app/data", "/app/config", "/var/log/caddy", "/var/log/crowdsec"}, roots) +} + +func TestSystemPermissionsHandler_IsOwnedByFalseWhenSysNotStat(t *testing.T) { + owned := isOwnedBy(fakeNoStatFileInfo{}, os.Geteuid(), os.Getegid()) + require.False(t, owned) +} + +func TestSystemPermissionsHandler_IsWithinAllowlist_RelErrorBranch(t *testing.T) { + tmp := t.TempDir() + inAllow := filepath.Join(tmp, "a", "b") + require.NoError(t, os.MkdirAll(inAllow, 0o750)) + + badRoot := string([]byte{'/', 0, 'x'}) + allowed := isWithinAllowlist(inAllow, []string{badRoot, tmp}) + require.True(t, allowed) +} + +func TestSystemPermissionsHandler_IsWithinAllowlist_AllRelErrorsReturnFalse(t *testing.T) { + badRoot1 := string([]byte{'/', 0, 'x'}) + badRoot2 := string([]byte{'/', 0, 'y'}) + allowed := isWithinAllowlist("/tmp/some/path", []string{badRoot1, badRoot2}) + require.False(t, allowed) +} + +func TestSystemPermissionsHandler_LogAudit_PersistsAuditWithUserID(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityAudit{})) + + securitySvc := services.NewSecurityService(db) + h := NewSystemPermissionsHandler(config.Config{}, securitySvc, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Set("userID", 42) + c.Request = httptest.NewRequest(http.MethodGet, "/system/permissions", http.NoBody) + + require.NotPanics(t, func() { + h.logAudit(c, "permissions_diagnostics", "ok", "", 2) + }) +} + +func TestSystemPermissionsHandler_LogAudit_PersistsAuditWithUnknownActor(t *testing.T) { + gin.SetMode(gin.TestMode) + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&models.SecurityAudit{})) + + securitySvc := services.NewSecurityService(db) + h := NewSystemPermissionsHandler(config.Config{}, securitySvc, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Request = httptest.NewRequest(http.MethodGet, "/system/permissions", http.NoBody) + + require.NotPanics(t, func() { + h.logAudit(c, "permissions_diagnostics", "ok", "", 1) + }) +} + func TestSystemPermissionsHandler_RepairPath_Branches(t *testing.T) { h := NewSystemPermissionsHandler(config.Config{}, nil, stubPermissionChecker{}) allowRoot := t.TempDir() @@ -360,3 +506,87 @@ func TestSystemPermissionsHandler_RepairPath_Branches(t *testing.T) { require.Equal(t, "0600", result.ModeAfter) }) } + +func TestSystemPermissionsHandler_OSChecker_Check(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("test expects root-owned temp paths in CI") + } + + tmp := t.TempDir() + filePath := filepath.Join(tmp, "check.txt") + require.NoError(t, os.WriteFile(filePath, []byte("ok"), 0o600)) + + checker := OSChecker{} + result := checker.Check(filePath, "rw") + require.Equal(t, filePath, result.Path) + require.Equal(t, "rw", result.Required) + require.True(t, result.Exists) +} + +func TestSystemPermissionsHandler_RepairPermissions_InvalidRequestBody_Root(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("test requires root execution") + } + + gin.SetMode(gin.TestMode) + + tmp := t.TempDir() + dataDir := filepath.Join(tmp, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o750)) + + h := NewSystemPermissionsHandler(config.Config{ + SingleContainer: true, + DatabasePath: filepath.Join(dataDir, "charon.db"), + ConfigRoot: dataDir, + CaddyLogDir: dataDir, + CrowdSecLogDir: dataDir, + PluginsDir: filepath.Join(tmp, "plugins"), + }, nil, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"group_mode":true}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.RepairPermissions(c) + require.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSystemPermissionsHandler_RepairPath_LstatInvalidArgument(t *testing.T) { + h := NewSystemPermissionsHandler(config.Config{}, nil, stubPermissionChecker{}) + allowRoot := t.TempDir() + + result := h.repairPath("/tmp/\x00invalid", false, []string{allowRoot}) + require.Equal(t, "error", result.Status) + require.Equal(t, "permissions_repair_failed", result.ErrorCode) +} + +func TestSystemPermissionsHandler_RepairPath_RepairedBranch(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("test requires root execution") + } + + h := NewSystemPermissionsHandler(config.Config{}, nil, stubPermissionChecker{}) + allowRoot := t.TempDir() + targetFile := filepath.Join(allowRoot, "needs-repair.txt") + require.NoError(t, os.WriteFile(targetFile, []byte("ok"), 0o600)) + + result := h.repairPath(targetFile, true, []string{allowRoot}) + require.Equal(t, "repaired", result.Status) + require.Equal(t, "0660", result.ModeAfter) + + info, err := os.Stat(targetFile) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o660), info.Mode().Perm()) +} + +func TestSystemPermissionsHandler_NormalizePath_ParentRefBranches(t *testing.T) { + clean, code := normalizePath("/../etc") + require.Equal(t, "/etc", clean) + require.Empty(t, code) + + clean, code = normalizePath("/var/../etc") + require.Equal(t, "/etc", clean) + require.Empty(t, code) +} diff --git a/backend/internal/api/handlers/system_permissions_wave6_test.go b/backend/internal/api/handlers/system_permissions_wave6_test.go new file mode 100644 index 00000000..ad2d7e63 --- /dev/null +++ b/backend/internal/api/handlers/system_permissions_wave6_test.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/Wikid82/charon/backend/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestSystemPermissionsWave6_RepairPermissions_NonRootBranchViaSeteuid(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("test requires root execution") + } + + if err := syscall.Seteuid(65534); err != nil { + t.Skip("unable to drop euid for test") + } + defer func() { + restoreErr := syscall.Seteuid(0) + require.NoError(t, restoreErr) + }() + + gin.SetMode(gin.TestMode) + + root := t.TempDir() + dataDir := filepath.Join(root, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o750)) + + h := NewSystemPermissionsHandler(config.Config{ + SingleContainer: true, + DatabasePath: filepath.Join(dataDir, "charon.db"), + ConfigRoot: dataDir, + CaddyLogDir: dataDir, + CrowdSecLogDir: dataDir, + }, nil, stubPermissionChecker{}) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", "admin") + c.Request = httptest.NewRequest(http.MethodPost, "/system/permissions/repair", bytes.NewBufferString(`{"paths":["/tmp"]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.RepairPermissions(c) + + require.Equal(t, http.StatusForbidden, w.Code) + var payload map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload)) + require.Equal(t, "permissions_non_root", payload["error_code"]) +} diff --git a/backend/internal/api/routes/routes_test.go b/backend/internal/api/routes/routes_test.go index f1d32f18..ebcd8769 100644 --- a/backend/internal/api/routes/routes_test.go +++ b/backend/internal/api/routes/routes_test.go @@ -3,6 +3,8 @@ package routes import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -1164,3 +1166,20 @@ func TestEmergencyBypass_UnauthorizedIP(t *testing.T) { // Should not activate bypass (unauthorized IP) assert.NotEqual(t, http.StatusNotFound, w.Code) } + +func TestRegister_CreatesAccessLogFileForLogWatcher(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_test_access_log_create"), &gorm.Config{}) + require.NoError(t, err) + + logFilePath := filepath.Join(t.TempDir(), "logs", "access.log") + t.Setenv("CHARON_CADDY_ACCESS_LOG", logFilePath) + + cfg := config.Config{JWTSecret: "test-secret"} + require.NoError(t, Register(router, db, cfg)) + + _, statErr := os.Stat(logFilePath) + assert.NoError(t, statErr) +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 82b97516..4cbd3865 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -130,6 +130,28 @@ func TestGetEnvAny(t *testing.T) { assert.Equal(t, "fallback", result) // Empty strings are treated as not set } +func TestGetEnvIntAny(t *testing.T) { + t.Run("returns fallback when unset", func(t *testing.T) { + assert.Equal(t, 42, getEnvIntAny(42, "MISSING_INT_A", "MISSING_INT_B")) + }) + + t.Run("returns parsed value from first key", func(t *testing.T) { + t.Setenv("TEST_INT_A", "123") + assert.Equal(t, 123, getEnvIntAny(42, "TEST_INT_A", "TEST_INT_B")) + }) + + t.Run("returns parsed value from second key", func(t *testing.T) { + t.Setenv("TEST_INT_A", "") + t.Setenv("TEST_INT_B", "77") + assert.Equal(t, 77, getEnvIntAny(42, "TEST_INT_A", "TEST_INT_B")) + }) + + t.Run("returns fallback when parse fails", func(t *testing.T) { + t.Setenv("TEST_INT_BAD", "not-a-number") + assert.Equal(t, 42, getEnvIntAny(42, "TEST_INT_BAD")) + }) +} + func TestLoad_SecurityConfig(t *testing.T) { tempDir := t.TempDir() t.Setenv("CHARON_DB_PATH", filepath.Join(tempDir, "test.db")) diff --git a/backend/internal/crypto/rotation_service_test.go b/backend/internal/crypto/rotation_service_test.go index 51aab9d9..aae98c2d 100644 --- a/backend/internal/crypto/rotation_service_test.go +++ b/backend/internal/crypto/rotation_service_test.go @@ -531,3 +531,34 @@ func TestRotationServiceZeroDowntime(t *testing.T) { assert.Equal(t, "secret", credentials["api_key"]) }) } + +func TestRotateProviderCredentials_InvalidJSONAfterDecrypt(t *testing.T) { + db := setupTestDB(t) + currentKey, nextKey, _ := setupTestKeys(t) + + currentService, err := NewEncryptionService(currentKey) + require.NoError(t, err) + + invalidJSONPlaintext := []byte("not-json") + encrypted, err := currentService.Encrypt(invalidJSONPlaintext) + require.NoError(t, err) + + provider := models.DNSProvider{ + UUID: "test-invalid-json", + Name: "Invalid JSON Provider", + ProviderType: "cloudflare", + CredentialsEncrypted: encrypted, + KeyVersion: 1, + } + require.NoError(t, db.Create(&provider).Error) + + require.NoError(t, os.Setenv("CHARON_ENCRYPTION_KEY_NEXT", nextKey)) + defer func() { _ = os.Unsetenv("CHARON_ENCRYPTION_KEY_NEXT") }() + + rs, err := NewRotationService(db) + require.NoError(t, err) + + err = rs.rotateProviderCredentials(context.Background(), &provider) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid credential format after decryption") +} diff --git a/backend/internal/patchreport/patchreport_test.go b/backend/internal/patchreport/patchreport_test.go index 8c798d69..0aa5e80f 100644 --- a/backend/internal/patchreport/patchreport_test.go +++ b/backend/internal/patchreport/patchreport_test.go @@ -86,6 +86,18 @@ func TestResolveThreshold(t *testing.T) { } } +func TestResolveThreshold_WithNilLookupUsesOSLookupEnv(t *testing.T) { + t.Setenv("PATCH_THRESHOLD_TEST", "91.2") + + resolved := ResolveThreshold("PATCH_THRESHOLD_TEST", 85.0, nil) + if resolved.Value != 91.2 { + t.Fatalf("expected env value 91.2, got %.1f", resolved.Value) + } + if resolved.Source != "env" { + t.Fatalf("expected source env, got %s", resolved.Source) + } +} + func TestParseUnifiedDiffChangedLines(t *testing.T) { t.Parallel() @@ -116,6 +128,26 @@ index 3333333..4444444 100644 assertHasLines(t, frontendChanged, "frontend/src/App.tsx", []int{21, 22}) } +func TestParseUnifiedDiffChangedLines_InvalidHunkStartReturnsError(t *testing.T) { + t.Parallel() + + diff := `diff --git a/backend/internal/app.go b/backend/internal/app.go +index 1111111..2222222 100644 +--- a/backend/internal/app.go ++++ b/backend/internal/app.go +@@ -1,1 +abc,2 @@ ++line +` + + backendChanged, frontendChanged, err := ParseUnifiedDiffChangedLines(diff) + if err != nil { + t.Fatalf("expected graceful handling for invalid hunk, got error: %v", err) + } + if len(backendChanged) != 0 || len(frontendChanged) != 0 { + t.Fatalf("expected no changed lines for invalid hunk, got backend=%v frontend=%v", backendChanged, frontendChanged) + } +} + func TestBackendChangedLineCoverageComputation(t *testing.T) { t.Parallel() @@ -347,6 +379,30 @@ func TestComputeFilesNeedingCoverage_IncludesUncoveredAndSortsDeterministically( } } +func TestComputeFilesNeedingCoverage_IncludesFullyCoveredWhenThresholdAbove100(t *testing.T) { + t.Parallel() + + changed := FileLineSet{ + "backend/internal/fully.go": {10: {}, 11: {}}, + } + coverage := CoverageData{ + Executable: FileLineSet{ + "backend/internal/fully.go": {10: {}, 11: {}}, + }, + Covered: FileLineSet{ + "backend/internal/fully.go": {10: {}, 11: {}}, + }, + } + + details := ComputeFilesNeedingCoverage(changed, coverage, 101) + if len(details) != 1 { + t.Fatalf("expected 1 file detail when threshold is 101, got %d", len(details)) + } + if details[0].PatchCoveragePct != 100.0 { + t.Fatalf("expected 100%% patch coverage detail, got %.1f", details[0].PatchCoveragePct) + } +} + func TestMergeFileCoverageDetails_SortsWorstCoverageThenPath(t *testing.T) { t.Parallel() @@ -371,3 +427,113 @@ func TestMergeFileCoverageDetails_SortsWorstCoverageThenPath(t *testing.T) { t.Fatalf("unexpected merged order: got %s want %s", got, want) } } + +func TestParseCoverageRange_ErrorBranches(t *testing.T) { + t.Parallel() + + _, _, _, err := parseCoverageRange("missing-colon") + if err == nil { + t.Fatal("expected error for missing colon") + } + + _, _, _, err = parseCoverageRange("file.go:10.1") + if err == nil { + t.Fatal("expected error for missing end coordinate") + } + + _, _, _, err = parseCoverageRange("file.go:bad.1,10.1") + if err == nil { + t.Fatal("expected error for bad start line") + } + + _, _, _, err = parseCoverageRange("file.go:10.1,9.1") + if err == nil { + t.Fatal("expected error for reversed range") + } +} + +func TestSortedWarnings_FiltersBlanksAndSorts(t *testing.T) { + t.Parallel() + + sorted := SortedWarnings([]string{"z warning", "", " ", "a warning"}) + got := strings.Join(sorted, ",") + want := "a warning,z warning" + if got != want { + t.Fatalf("unexpected warnings ordering: got %q want %q", got, want) + } +} + +func TestNormalizePathsAndRanges(t *testing.T) { + t.Parallel() + + if got := normalizeGoCoveragePath("internal/service.go"); got != "backend/internal/service.go" { + t.Fatalf("unexpected normalized go path: %s", got) + } + + if got := normalizeGoCoveragePath("/tmp/work/backend/internal/service.go"); got != "backend/internal/service.go" { + t.Fatalf("unexpected backend extraction path: %s", got) + } + + frontend := normalizeFrontendCoveragePaths("/tmp/work/frontend/src/App.tsx") + if len(frontend) == 0 { + t.Fatal("expected frontend normalized paths") + } + + ranges := formatLineRanges([]int{1, 2, 3, 7, 9, 10}) + gotRanges := strings.Join(ranges, ",") + wantRanges := "1-3,7,9-10" + if gotRanges != wantRanges { + t.Fatalf("unexpected ranges: got %q want %q", gotRanges, wantRanges) + } +} + +func TestScopeCoverageMergeAndStatus(t *testing.T) { + t.Parallel() + + merged := MergeScopeCoverage( + ScopeCoverage{ChangedLines: 4, CoveredLines: 3}, + ScopeCoverage{ChangedLines: 0, CoveredLines: 0}, + ) + + if merged.ChangedLines != 4 || merged.CoveredLines != 3 || merged.PatchCoveragePct != 75.0 { + t.Fatalf("unexpected merged scope: %+v", merged) + } + + if status := ApplyStatus(merged, 70); status.Status != "pass" { + t.Fatalf("expected pass status, got %s", status.Status) + } +} + +func TestParseCoverageProfiles_InvalidPath(t *testing.T) { + t.Parallel() + + _, err := ParseGoCoverageProfile(" ") + if err == nil { + t.Fatal("expected go profile path validation error") + } + + _, err = ParseLCOVProfile("\t") + if err == nil { + t.Fatal("expected lcov profile path validation error") + } +} + +func TestNormalizeFrontendCoveragePaths_EmptyInput(t *testing.T) { + t.Parallel() + + paths := normalizeFrontendCoveragePaths(" ") + if len(paths) == 0 { + t.Fatalf("expected normalized fallback paths, got %#v", paths) + } +} + +func TestAddLine_IgnoresInvalidInputs(t *testing.T) { + t.Parallel() + + set := make(FileLineSet) + addLine(set, "", 10) + addLine(set, "backend/internal/x.go", 0) + if len(set) != 0 { + t.Fatalf("expected no entries for invalid addLine input, got %#v", set) + } +} diff --git a/backend/internal/services/access_list_service_test.go b/backend/internal/services/access_list_service_test.go index a9be9c93..426968ec 100644 --- a/backend/internal/services/access_list_service_test.go +++ b/backend/internal/services/access_list_service_test.go @@ -198,6 +198,30 @@ func TestAccessListService_GetByUUID(t *testing.T) { }) } +func TestAccessListService_GetByID_DBError(t *testing.T) { + db := setupTestDB(t) + service := NewAccessListService(db) + + sqlDB, err := db.DB() + assert.NoError(t, err) + assert.NoError(t, sqlDB.Close()) + + _, err = service.GetByID(1) + assert.Error(t, err) +} + +func TestAccessListService_GetByUUID_DBError(t *testing.T) { + db := setupTestDB(t) + service := NewAccessListService(db) + + sqlDB, err := db.DB() + assert.NoError(t, err) + assert.NoError(t, sqlDB.Close()) + + _, err = service.GetByUUID("any") + assert.Error(t, err) +} + func TestAccessListService_List(t *testing.T) { db := setupTestDB(t) service := NewAccessListService(db) diff --git a/backend/internal/services/auth_service_test.go b/backend/internal/services/auth_service_test.go index ccb375fd..fedc4001 100644 --- a/backend/internal/services/auth_service_test.go +++ b/backend/internal/services/auth_service_test.go @@ -7,6 +7,7 @@ import ( "github.com/Wikid82/charon/backend/internal/config" "github.com/Wikid82/charon/backend/internal/models" + "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" @@ -288,3 +289,45 @@ func TestAuthService_InvalidateSessions(t *testing.T) { require.Error(t, err) assert.Equal(t, "user not found", err.Error()) } + +func TestAuthService_AuthenticateToken_InvalidUserIDInClaims(t *testing.T) { + db := setupAuthTestDB(t) + cfg := config.Config{JWTSecret: "test-secret"} + service := NewAuthService(db, cfg) + + user, err := service.Register("claims@example.com", "password123", "Claims User") + require.NoError(t, err) + + claims := Claims{ + UserID: user.ID + 9999, + Role: "user", + SessionVersion: user.SessionVersion, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString([]byte(cfg.JWTSecret)) + require.NoError(t, err) + + _, _, err = service.AuthenticateToken(tokenString) + require.Error(t, err) + assert.Equal(t, "invalid token", err.Error()) +} + +func TestAuthService_InvalidateSessions_DBError(t *testing.T) { + db := setupAuthTestDB(t) + cfg := config.Config{JWTSecret: "test-secret"} + service := NewAuthService(db, cfg) + + user, err := service.Register("dberror@example.com", "password123", "DB Error User") + require.NoError(t, err) + + sqlDB, err := db.DB() + require.NoError(t, err) + require.NoError(t, sqlDB.Close()) + + err = service.InvalidateSessions(user.ID) + require.Error(t, err) +} diff --git a/backend/internal/services/backup_service_rehydrate_test.go b/backend/internal/services/backup_service_rehydrate_test.go index 2d4c314a..0034d940 100644 --- a/backend/internal/services/backup_service_rehydrate_test.go +++ b/backend/internal/services/backup_service_rehydrate_test.go @@ -2,6 +2,7 @@ package services import ( "archive/zip" + "fmt" "io" "os" "path/filepath" @@ -16,6 +17,18 @@ import ( "gorm.io/gorm" ) +func TestCreateSQLiteSnapshot_InvalidDBPath(t *testing.T) { + badPath := filepath.Join(t.TempDir(), "missing-parent", "missing.db") + _, _, err := createSQLiteSnapshot(badPath) + require.Error(t, err) +} + +func TestCheckpointSQLiteDatabase_InvalidDBPath(t *testing.T) { + badPath := filepath.Join(t.TempDir(), "missing-parent", "missing.db") + err := checkpointSQLiteDatabase(badPath) + require.Error(t, err) +} + func TestBackupService_RehydrateLiveDatabase(t *testing.T) { tmpDir := t.TempDir() dataDir := filepath.Join(tmpDir, "data") @@ -120,3 +133,122 @@ func TestBackupService_RehydrateLiveDatabase_FromBackupWithWAL(t *testing.T) { require.Len(t, restoredUsers, 1) assert.Equal(t, "restore-from-wal@example.com", restoredUsers[0].Email) } + +func TestBackupService_ExtractDatabaseFromBackup_WALCheckpointFailure(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "with-invalid-wal.zip") + + zipFile, err := os.Create(zipPath) //nolint:gosec + require.NoError(t, err) + writer := zip.NewWriter(zipFile) + + dbEntry, err := writer.Create("charon.db") + require.NoError(t, err) + _, err = dbEntry.Write([]byte("not-a-valid-sqlite-db")) + require.NoError(t, err) + + walEntry, err := writer.Create("charon.db-wal") + require.NoError(t, err) + _, err = walEntry.Write([]byte("not-a-valid-wal")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DatabaseName: "charon.db"} + _, err = svc.extractDatabaseFromBackup(zipPath) + require.Error(t, err) + require.Contains(t, err.Error(), "checkpoint extracted sqlite wal") +} + +func TestBackupService_RehydrateLiveDatabase_InvalidRestoreDB(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDBPath := filepath.Join(dataDir, "charon.db") + activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, activeDB.Exec("CREATE TABLE IF NOT EXISTS healthcheck (id INTEGER PRIMARY KEY, value TEXT)").Error) + + invalidRestorePath := filepath.Join(tmpDir, "invalid-restore.sqlite") + require.NoError(t, os.WriteFile(invalidRestorePath, []byte("invalid sqlite content"), 0o600)) + + svc := &BackupService{ + DataDir: dataDir, + DatabaseName: "charon.db", + restoreDBPath: invalidRestorePath, + } + + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "attach restored database") +} + +func TestBackupService_RehydrateLiveDatabase_InvalidTableIdentifier(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDBPath := filepath.Join(dataDir, "charon.db") + activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, activeDB.Exec("CREATE TABLE \"bad-name\" (id INTEGER PRIMARY KEY, value TEXT)").Error) + + restoreDBPath := filepath.Join(tmpDir, "restore.sqlite") + restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, restoreDB.Exec("CREATE TABLE \"bad-name\" (id INTEGER PRIMARY KEY, value TEXT)").Error) + require.NoError(t, restoreDB.Exec("INSERT INTO \"bad-name\" (value) VALUES (?)", "ok").Error) + + svc := &BackupService{ + DataDir: dataDir, + DatabaseName: "charon.db", + restoreDBPath: restoreDBPath, + } + + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "quote table identifier") +} + +func TestBackupService_CreateSQLiteSnapshot_TempDirInvalid(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "charon.db") + createSQLiteTestDB(t, dbPath) + + originalTmp := os.Getenv("TMPDIR") + t.Setenv("TMPDIR", filepath.Join(tmpDir, "nonexistent-tmp")) + defer func() { + _ = os.Setenv("TMPDIR", originalTmp) + }() + + _, _, err := createSQLiteSnapshot(dbPath) + require.Error(t, err) + require.Contains(t, err.Error(), "create sqlite snapshot file") +} + +func TestBackupService_RunScheduledBackup_CreateBackupAndCleanupHooks(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + cfg := &config.Config{DatabasePath: filepath.Join(dataDir, "charon.db")} + service := NewBackupService(cfg) + defer service.Stop() + + createCalls := 0 + cleanupCalls := 0 + service.createBackup = func() (string, error) { + createCalls++ + return fmt.Sprintf("backup-%d.zip", createCalls), nil + } + service.cleanupOld = func(keep int) (int, error) { + cleanupCalls++ + return 1, nil + } + + service.RunScheduledBackup() + require.Equal(t, 1, createCalls) + require.Equal(t, 1, cleanupCalls) +} diff --git a/backend/internal/services/backup_service_test.go b/backend/internal/services/backup_service_test.go index 0123ea10..7875f81b 100644 --- a/backend/internal/services/backup_service_test.go +++ b/backend/internal/services/backup_service_test.go @@ -1551,3 +1551,100 @@ func TestSafeJoinPath(t *testing.T) { assert.Equal(t, "/data/backups/backup.2024.01.01.zip", path) }) } + +func TestBackupService_RehydrateLiveDatabase_NilHandle(t *testing.T) { + tmpDir := t.TempDir() + svc := &BackupService{DataDir: tmpDir, DatabaseName: "charon.db"} + + err := svc.RehydrateLiveDatabase(nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "database handle is required") +} + +func TestBackupService_RehydrateLiveDatabase_MissingSource(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + dbPath := filepath.Join(dataDir, "charon.db") + createSQLiteTestDB(t, dbPath) + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{}) + require.NoError(t, err) + + svc := &BackupService{ + DataDir: dataDir, + DatabaseName: "charon.db", + restoreDBPath: filepath.Join(tmpDir, "missing-restore.sqlite"), + } + + require.NoError(t, os.Remove(dbPath)) + err = svc.RehydrateLiveDatabase(db) + require.Error(t, err) + assert.Contains(t, err.Error(), "restored database file missing") +} + +func TestBackupService_ExtractDatabaseFromBackup_MissingDBEntry(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "missing-db-entry.zip") + + zipFile, err := os.Create(zipPath) //nolint:gosec + require.NoError(t, err) + writer := zip.NewWriter(zipFile) + + entry, err := writer.Create("not-charon.db") + require.NoError(t, err) + _, err = entry.Write([]byte("placeholder")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DatabaseName: "charon.db"} + _, err = svc.extractDatabaseFromBackup(zipPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "database entry charon.db not found") +} + +func TestBackupService_RestoreBackup_ReplacesStagedRestoreSnapshot(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + backupDir := filepath.Join(tmpDir, "backups") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + require.NoError(t, os.MkdirAll(backupDir, 0o700)) + + createBackupZipWithDB := func(name string, content []byte) string { + path := filepath.Join(backupDir, name) + zipFile, err := os.Create(path) //nolint:gosec + require.NoError(t, err) + writer := zip.NewWriter(zipFile) + entry, err := writer.Create("charon.db") + require.NoError(t, err) + _, err = entry.Write(content) + require.NoError(t, err) + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + return path + } + + createBackupZipWithDB("backup-one.zip", []byte("one")) + createBackupZipWithDB("backup-two.zip", []byte("two")) + + svc := &BackupService{ + DataDir: dataDir, + BackupDir: backupDir, + DatabaseName: "charon.db", + restoreDBPath: "", + } + + require.NoError(t, svc.RestoreBackup("backup-one.zip")) + firstRestore := svc.restoreDBPath + assert.NotEmpty(t, firstRestore) + assert.FileExists(t, firstRestore) + + require.NoError(t, svc.RestoreBackup("backup-two.zip")) + secondRestore := svc.restoreDBPath + assert.NotEqual(t, firstRestore, secondRestore) + assert.NoFileExists(t, firstRestore) + assert.FileExists(t, secondRestore) +} diff --git a/backend/internal/services/backup_service_wave3_test.go b/backend/internal/services/backup_service_wave3_test.go new file mode 100644 index 00000000..0cabbb37 --- /dev/null +++ b/backend/internal/services/backup_service_wave3_test.go @@ -0,0 +1,92 @@ +package services + +import ( + "archive/zip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func openZipInTempDir(t *testing.T, tempDir, zipPath string) *os.File { + t.Helper() + + absTempDir, err := filepath.Abs(tempDir) + require.NoError(t, err) + absZipPath, err := filepath.Abs(zipPath) + require.NoError(t, err) + + relPath, err := filepath.Rel(absTempDir, absZipPath) + require.NoError(t, err) + require.False(t, relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator))) + + // #nosec G304 -- absZipPath is constrained to test TempDir via Abs+Rel checks above. + zipFile, err := os.OpenFile(absZipPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + require.NoError(t, err) + + return zipFile +} + +func TestBackupService_UnzipWithSkip_SkipsDatabaseEntries(t *testing.T) { + tmp := t.TempDir() + destDir := filepath.Join(tmp, "data") + require.NoError(t, os.MkdirAll(destDir, 0o700)) + + zipPath := filepath.Join(tmp, "restore.zip") + zipFile := openZipInTempDir(t, tmp, zipPath) + + writer := zip.NewWriter(zipFile) + for name, content := range map[string]string{ + "charon.db": "db", + "charon.db-wal": "wal", + "charon.db-shm": "shm", + "caddy/config": "cfg", + "nested/file.txt": "hello", + } { + entry, createErr := writer.Create(name) + require.NoError(t, createErr) + _, writeErr := entry.Write([]byte(content)) + require.NoError(t, writeErr) + } + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DataDir: destDir, DatabaseName: "charon.db"} + require.NoError(t, svc.unzipWithSkip(zipPath, destDir, map[string]struct{}{ + "charon.db": {}, + "charon.db-wal": {}, + "charon.db-shm": {}, + })) + + _, err := os.Stat(filepath.Join(destDir, "charon.db")) + require.Error(t, err) + require.FileExists(t, filepath.Join(destDir, "caddy", "config")) + require.FileExists(t, filepath.Join(destDir, "nested", "file.txt")) +} + +func TestBackupService_ExtractDatabaseFromBackup_ExtractWalFailure(t *testing.T) { + tmp := t.TempDir() + + zipPath := filepath.Join(tmp, "invalid-wal.zip") + zipFile := openZipInTempDir(t, tmp, zipPath) + writer := zip.NewWriter(zipFile) + + dbEntry, err := writer.Create("charon.db") + require.NoError(t, err) + _, err = dbEntry.Write([]byte("sqlite header placeholder")) + require.NoError(t, err) + + walEntry, err := writer.Create("charon.db-wal") + require.NoError(t, err) + _, err = walEntry.Write([]byte("invalid wal content")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DatabaseName: "charon.db"} + _, err = svc.extractDatabaseFromBackup(zipPath) + require.Error(t, err) +} diff --git a/backend/internal/services/backup_service_wave4_test.go b/backend/internal/services/backup_service_wave4_test.go new file mode 100644 index 00000000..8a2a535d --- /dev/null +++ b/backend/internal/services/backup_service_wave4_test.go @@ -0,0 +1,267 @@ +package services + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func openWave4ZipInTempDir(t *testing.T, tempDir, zipPath string) *os.File { + t.Helper() + + absTempDir, err := filepath.Abs(tempDir) + require.NoError(t, err) + absZipPath, err := filepath.Abs(zipPath) + require.NoError(t, err) + + relPath, err := filepath.Rel(absTempDir, absZipPath) + require.NoError(t, err) + require.False(t, relPath == ".." || strings.HasPrefix(relPath, ".."+string(filepath.Separator))) + + // #nosec G304 -- absZipPath is constrained to test TempDir via Abs+Rel checks above. + zipFile, err := os.OpenFile(absZipPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + require.NoError(t, err) + + return zipFile +} + +func registerBackupRawErrorHook(t *testing.T, db *gorm.DB, name string, shouldFail func(*gorm.DB) bool) { + t.Helper() + require.NoError(t, db.Callback().Raw().Before("gorm:raw").Register(name, func(tx *gorm.DB) { + if shouldFail(tx) { + _ = tx.AddError(fmt.Errorf("forced raw failure")) + } + })) + t.Cleanup(func() { + _ = db.Callback().Raw().Remove(name) + }) +} + +func backupSQLContains(tx *gorm.DB, fragment string) bool { + if tx == nil || tx.Statement == nil { + return false + } + return strings.Contains(strings.ToLower(tx.Statement.SQL.String()), strings.ToLower(fragment)) +} + +func setupRehydrateDBPair(t *testing.T) (*gorm.DB, string, string) { + t.Helper() + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDBPath := filepath.Join(tmpDir, "active.db") + activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`).Error) + + restoreDBPath := filepath.Join(tmpDir, "restore.db") + restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`).Error) + require.NoError(t, restoreDB.Exec(`INSERT INTO users (name) VALUES ('alice')`).Error) + + return activeDB, dataDir, restoreDBPath +} + +func TestBackupServiceWave4_Rehydrate_CheckpointWarningPath(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDBPath := filepath.Join(tmpDir, "active.db") + activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) + require.NoError(t, err) + + // Place an invalid database file at DataDir/DatabaseName so checkpointSQLiteDatabase fails + restoredDBPath := filepath.Join(dataDir, "charon.db") + require.NoError(t, os.WriteFile(restoredDBPath, []byte("not-sqlite"), 0o600)) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db"} + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) +} + +func TestBackupServiceWave4_Rehydrate_CreateTempFailure(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + dbPath := filepath.Join(dataDir, "charon.db") + createSQLiteTestDB(t, dbPath) + + activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) + require.NoError(t, err) + + t.Setenv("TMPDIR", filepath.Join(tmpDir, "missing-temp-dir")) + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db"} + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "create temporary restore database copy") +} + +func TestBackupServiceWave4_Rehydrate_CopyErrorFromDirectorySource(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) + require.NoError(t, err) + + // Use a directory as restore source path so io.Copy fails deterministically. + badSourceDir := filepath.Join(tmpDir, "restore-source-dir") + require.NoError(t, os.MkdirAll(badSourceDir, 0o700)) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: badSourceDir} + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "copy restored database to temporary file") +} + +func TestBackupServiceWave4_Rehydrate_CopyTableErrorOnSchemaMismatch(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDBPath := filepath.Join(tmpDir, "active.db") + activeDB, err := gorm.Open(sqlite.Open(activeDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`).Error) + + restoreDBPath := filepath.Join(tmpDir, "restore.db") + restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, extra TEXT)`).Error) + require.NoError(t, restoreDB.Exec(`INSERT INTO users (name, extra) VALUES ('alice', 'x')`).Error) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "copy table users") +} + +func TestBackupServiceWave4_ExtractDatabaseFromBackup_CreateTempError(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "backup.zip") + + zf := openWave4ZipInTempDir(t, tmpDir, zipPath) + zw := zip.NewWriter(zf) + entry, err := zw.Create("charon.db") + require.NoError(t, err) + _, err = entry.Write([]byte("sqlite-header-placeholder")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.NoError(t, zf.Close()) + + t.Setenv("TMPDIR", filepath.Join(tmpDir, "missing-temp-dir")) + + svc := &BackupService{DatabaseName: "charon.db"} + _, err = svc.extractDatabaseFromBackup(zipPath) + require.Error(t, err) + require.Contains(t, err.Error(), "create restore snapshot file") +} + +func TestBackupServiceWave4_UnzipWithSkip_MkdirParentError(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "nested.zip") + + zf := openWave4ZipInTempDir(t, tmpDir, zipPath) + zw := zip.NewWriter(zf) + entry, err := zw.Create("nested/file.txt") + require.NoError(t, err) + _, err = entry.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, zw.Close()) + require.NoError(t, zf.Close()) + + // Make destination a regular file so MkdirAll(filepath.Dir(fpath)) fails with ENOTDIR. + destFile := filepath.Join(tmpDir, "dest-as-file") + require.NoError(t, os.WriteFile(destFile, []byte("block"), 0o600)) + + svc := &BackupService{} + err = svc.unzipWithSkip(zipPath, destFile, nil) + require.Error(t, err) +} + +func TestBackupServiceWave4_Rehydrate_ClearSQLiteSequenceError(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) + + restoreDBPath := filepath.Join(tmpDir, "restore.db") + restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) + require.NoError(t, restoreDB.Exec(`INSERT INTO users (name) VALUES ('alice')`).Error) + + registerBackupRawErrorHook(t, activeDB, "wave4-clear-sqlite-sequence", func(tx *gorm.DB) bool { + return backupSQLContains(tx, "delete from sqlite_sequence") + }) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "clear sqlite_sequence") +} + +func TestBackupServiceWave4_Rehydrate_CopySQLiteSequenceError(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + + activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, activeDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) + + restoreDBPath := filepath.Join(tmpDir, "restore.db") + restoreDB, err := gorm.Open(sqlite.Open(restoreDBPath), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, restoreDB.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`).Error) + require.NoError(t, restoreDB.Exec(`INSERT INTO users (name) VALUES ('alice')`).Error) + + registerBackupRawErrorHook(t, activeDB, "wave4-copy-sqlite-sequence", func(tx *gorm.DB) bool { + return backupSQLContains(tx, "insert into sqlite_sequence select * from restore_src.sqlite_sequence") + }) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} + err = svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "copy sqlite_sequence") +} + +func TestBackupServiceWave4_Rehydrate_DetachErrorNotBusyOrLocked(t *testing.T) { + activeDB, dataDir, restoreDBPath := setupRehydrateDBPair(t) + + registerBackupRawErrorHook(t, activeDB, "wave4-detach-error", func(tx *gorm.DB) bool { + return backupSQLContains(tx, "detach database restore_src") + }) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} + err := svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "detach restored database") +} + +func TestBackupServiceWave4_Rehydrate_WALCheckpointErrorNotBusyOrLocked(t *testing.T) { + activeDB, dataDir, restoreDBPath := setupRehydrateDBPair(t) + + registerBackupRawErrorHook(t, activeDB, "wave4-wal-checkpoint-error", func(tx *gorm.DB) bool { + return backupSQLContains(tx, "pragma wal_checkpoint(truncate)") + }) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} + err := svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "checkpoint wal after rehydrate") +} diff --git a/backend/internal/services/backup_service_wave5_test.go b/backend/internal/services/backup_service_wave5_test.go new file mode 100644 index 00000000..8cbb93f5 --- /dev/null +++ b/backend/internal/services/backup_service_wave5_test.go @@ -0,0 +1,56 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestBackupServiceWave5_Rehydrate_FallbackWhenRestorePathMissing(t *testing.T) { + tmpDir := t.TempDir() + dataDir := filepath.Join(tmpDir, "data") + require.NoError(t, os.MkdirAll(dataDir, 0o700)) + restoredDBPath := filepath.Join(dataDir, "charon.db") + createSQLiteTestDB(t, restoredDBPath) + + activeDB, err := gorm.Open(sqlite.Open(filepath.Join(tmpDir, "active.db")), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, activeDB.Exec(`CREATE TABLE healthcheck (id INTEGER PRIMARY KEY, value TEXT)`).Error) + + svc := &BackupService{ + DataDir: dataDir, + DatabaseName: "charon.db", + restoreDBPath: filepath.Join(tmpDir, "missing-restore.sqlite"), + } + require.NoError(t, svc.RehydrateLiveDatabase(activeDB)) +} + +func TestBackupServiceWave5_Rehydrate_DisableForeignKeysError(t *testing.T) { + activeDB, dataDir, restoreDBPath := setupRehydrateDBPair(t) + + registerBackupRawErrorHook(t, activeDB, "wave5-disable-fk", func(tx *gorm.DB) bool { + return backupSQLContains(tx, "pragma foreign_keys = off") + }) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} + err := svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "disable foreign keys") +} + +func TestBackupServiceWave5_Rehydrate_ClearTableError(t *testing.T) { + activeDB, dataDir, restoreDBPath := setupRehydrateDBPair(t) + + registerBackupRawErrorHook(t, activeDB, "wave5-clear-users", func(tx *gorm.DB) bool { + return backupSQLContains(tx, "delete from \"users\"") + }) + + svc := &BackupService{DataDir: dataDir, DatabaseName: "charon.db", restoreDBPath: restoreDBPath} + err := svc.RehydrateLiveDatabase(activeDB) + require.Error(t, err) + require.Contains(t, err.Error(), "clear table users") +} diff --git a/backend/internal/services/backup_service_wave6_test.go b/backend/internal/services/backup_service_wave6_test.go new file mode 100644 index 00000000..8fae210d --- /dev/null +++ b/backend/internal/services/backup_service_wave6_test.go @@ -0,0 +1,49 @@ +package services + +import ( + "archive/zip" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBackupServiceWave6_ExtractDatabaseFromBackup_WithShmEntry(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "charon.db") + createSQLiteTestDB(t, dbPath) + + zipPath := filepath.Join(tmpDir, "with-shm.zip") + zipFile, err := os.Create(zipPath) // #nosec G304 -- path is derived from t.TempDir() + require.NoError(t, err) + writer := zip.NewWriter(zipFile) + + sourceDB, err := os.Open(dbPath) // #nosec G304 -- path is derived from t.TempDir() + require.NoError(t, err) + defer func() { _ = sourceDB.Close() }() + + dbEntry, err := writer.Create("charon.db") + require.NoError(t, err) + _, err = io.Copy(dbEntry, sourceDB) + require.NoError(t, err) + + walEntry, err := writer.Create("charon.db-wal") + require.NoError(t, err) + _, err = walEntry.Write([]byte("invalid wal content")) + require.NoError(t, err) + + shmEntry, err := writer.Create("charon.db-shm") + require.NoError(t, err) + _, err = shmEntry.Write([]byte("shm placeholder")) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DatabaseName: "charon.db"} + restoredPath, err := svc.extractDatabaseFromBackup(zipPath) + require.NoError(t, err) + require.FileExists(t, restoredPath) +} diff --git a/backend/internal/services/backup_service_wave7_test.go b/backend/internal/services/backup_service_wave7_test.go new file mode 100644 index 00000000..013d7a0b --- /dev/null +++ b/backend/internal/services/backup_service_wave7_test.go @@ -0,0 +1,97 @@ +package services + +import ( + "archive/zip" + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func writeLargeZipEntry(t *testing.T, writer *zip.Writer, name string, sizeBytes int64) { + t.Helper() + entry, err := writer.Create(name) + require.NoError(t, err) + + chunk := bytes.Repeat([]byte{0}, 1024*1024) + remaining := sizeBytes + for remaining > 0 { + toWrite := int64(len(chunk)) + if remaining < toWrite { + toWrite = remaining + } + _, err := entry.Write(chunk[:toWrite]) + require.NoError(t, err) + remaining -= toWrite + } +} + +func TestBackupServiceWave7_CreateBackup_SnapshotFailureForNonSQLiteDB(t *testing.T) { + tmpDir := t.TempDir() + backupDir := filepath.Join(tmpDir, "backups") + require.NoError(t, os.MkdirAll(backupDir, 0o700)) + + dbPath := filepath.Join(tmpDir, "charon.db") + require.NoError(t, os.WriteFile(dbPath, []byte("not-a-sqlite-db"), 0o600)) + + svc := &BackupService{ + DataDir: tmpDir, + BackupDir: backupDir, + DatabaseName: "charon.db", + } + + _, err := svc.CreateBackup() + require.Error(t, err) + require.Contains(t, err.Error(), "create sqlite snapshot before backup") +} + +func TestBackupServiceWave7_ExtractDatabaseFromBackup_DBEntryOverLimit(t *testing.T) { + tmpDir := t.TempDir() + zipPath := filepath.Join(tmpDir, "db-over-limit.zip") + + zipFile, err := os.Create(zipPath) // #nosec G304 -- path is derived from t.TempDir() + require.NoError(t, err) + writer := zip.NewWriter(zipFile) + + writeLargeZipEntry(t, writer, "charon.db", int64(101*1024*1024)) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DatabaseName: "charon.db"} + _, err = svc.extractDatabaseFromBackup(zipPath) + require.Error(t, err) + require.Contains(t, err.Error(), "extract database entry from backup archive") + require.Contains(t, err.Error(), "decompression limit") +} + +func TestBackupServiceWave7_ExtractDatabaseFromBackup_WALEntryOverLimit(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "charon.db") + createSQLiteTestDB(t, dbPath) + + zipPath := filepath.Join(tmpDir, "wal-over-limit.zip") + zipFile, err := os.Create(zipPath) // #nosec G304 -- path is derived from t.TempDir() + require.NoError(t, err) + writer := zip.NewWriter(zipFile) + + dbBytes, err := os.ReadFile(dbPath) // #nosec G304 -- path is derived from t.TempDir() + require.NoError(t, err) + dbEntry, err := writer.Create("charon.db") + require.NoError(t, err) + _, err = dbEntry.Write(dbBytes) + require.NoError(t, err) + + writeLargeZipEntry(t, writer, "charon.db-wal", int64(101*1024*1024)) + + require.NoError(t, writer.Close()) + require.NoError(t, zipFile.Close()) + + svc := &BackupService{DatabaseName: "charon.db"} + _, err = svc.extractDatabaseFromBackup(zipPath) + require.Error(t, err) + require.Contains(t, err.Error(), "extract wal entry from backup archive") + require.Contains(t, err.Error(), "decompression limit") +} diff --git a/backend/internal/services/crowdsec_startup_test.go b/backend/internal/services/crowdsec_startup_test.go index f095941f..b259496d 100644 --- a/backend/internal/services/crowdsec_startup_test.go +++ b/backend/internal/services/crowdsec_startup_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "fmt" "os" "path/filepath" "testing" @@ -542,6 +543,30 @@ func TestReconcileCrowdSecOnStartup_CreateConfigDBError(t *testing.T) { assert.False(t, exec.startCalled) } +func TestReconcileCrowdSecOnStartup_CreateConfigCallbackError(t *testing.T) { + db := setupCrowdsecTestDB(t) + binPath, dataDir, cleanup := setupCrowdsecTestFixtures(t) + defer cleanup() + + cbName := "test:force-create-config-error" + err := db.Callback().Create().Before("gorm:create").Register(cbName, func(tx *gorm.DB) { + if tx.Statement != nil && tx.Statement.Schema != nil && tx.Statement.Schema.Name == "SecurityConfig" { + _ = tx.AddError(fmt.Errorf("forced security config create error")) + } + }) + require.NoError(t, err) + t.Cleanup(func() { + _ = db.Callback().Create().Remove(cbName) + }) + + exec := &smartMockCrowdsecExecutor{startPid: 99999} + cmdExec := &mockCommandExecutor{} + + ReconcileCrowdSecOnStartup(db, exec, binPath, dataDir, cmdExec) + + assert.False(t, exec.startCalled) +} + func TestReconcileCrowdSecOnStartup_SettingsTableQueryError(t *testing.T) { db := setupCrowdsecTestDB(t) binPath, dataDir, cleanup := setupCrowdsecTestFixtures(t) diff --git a/backend/internal/services/log_service_test.go b/backend/internal/services/log_service_test.go index 1074b041..f94b39a9 100644 --- a/backend/internal/services/log_service_test.go +++ b/backend/internal/services/log_service_test.go @@ -192,3 +192,23 @@ func TestLogService_logDirsAndSymlinkDedup(t *testing.T) { assert.Len(t, logs, 1) assert.Equal(t, "access.log", logs[0].Name) } + +func TestLogService_logDirs_SkipsDotAndEmpty(t *testing.T) { + t.Setenv("CHARON_CADDY_ACCESS_LOG", filepath.Join(t.TempDir(), "caddy", "access.log")) + + service := &LogService{LogDir: ".", CaddyLogDir: ""} + dirs := service.logDirs() + + require.Len(t, dirs, 1) + assert.NotEqual(t, ".", dirs[0]) +} + +func TestLogService_ListLogs_ReadDirError(t *testing.T) { + tmpDir := t.TempDir() + notDir := filepath.Join(tmpDir, "not-a-dir") + require.NoError(t, os.WriteFile(notDir, []byte("x"), 0o600)) + + service := &LogService{LogDir: notDir} + _, err := service.ListLogs() + require.Error(t, err) +} diff --git a/backend/internal/services/proxyhost_service_validation_test.go b/backend/internal/services/proxyhost_service_validation_test.go index f4420622..92634d7a 100644 --- a/backend/internal/services/proxyhost_service_validation_test.go +++ b/backend/internal/services/proxyhost_service_validation_test.go @@ -210,6 +210,7 @@ func TestProxyHostService_ValidateHostname(t *testing.T) { }{ {name: "plain hostname", host: "example.com", wantErr: false}, {name: "hostname with scheme", host: "https://example.com", wantErr: false}, + {name: "hostname with http scheme", host: "http://example.com", wantErr: false}, {name: "hostname with port", host: "example.com:8080", wantErr: false}, {name: "ipv4 address", host: "127.0.0.1", wantErr: false}, {name: "bracketed ipv6 with port", host: "[::1]:443", wantErr: false}, diff --git a/backend/internal/services/security_headers_service_test.go b/backend/internal/services/security_headers_service_test.go index 12a38aa0..38ce8a9e 100644 --- a/backend/internal/services/security_headers_service_test.go +++ b/backend/internal/services/security_headers_service_test.go @@ -1,10 +1,12 @@ package services import ( + "fmt" "testing" "github.com/Wikid82/charon/backend/internal/models" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) @@ -330,3 +332,41 @@ func TestApplyPreset_MultipleProfiles(t *testing.T) { db.Model(&models.SecurityHeaderProfile{}).Count(&count) assert.Equal(t, int64(2), count) } + +func TestEnsurePresetsExist_CreateError(t *testing.T) { + db := setupSecurityHeadersServiceDB(t) + service := NewSecurityHeadersService(db) + + cbName := "test:create-error" + err := db.Callback().Create().Before("gorm:create").Register(cbName, func(tx *gorm.DB) { + _ = tx.AddError(fmt.Errorf("forced create error")) + }) + assert.NoError(t, err) + t.Cleanup(func() { + _ = db.Callback().Create().Remove(cbName) + }) + + err = service.EnsurePresetsExist() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create preset") +} + +func TestEnsurePresetsExist_SaveError(t *testing.T) { + db := setupSecurityHeadersServiceDB(t) + service := NewSecurityHeadersService(db) + + require.NoError(t, service.EnsurePresetsExist()) + + cbName := "test:update-error" + err := db.Callback().Update().Before("gorm:update").Register(cbName, func(tx *gorm.DB) { + _ = tx.AddError(fmt.Errorf("forced update error")) + }) + assert.NoError(t, err) + t.Cleanup(func() { + _ = db.Callback().Update().Remove(cbName) + }) + + err = service.EnsurePresetsExist() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to update preset") +} diff --git a/backend/internal/util/permissions_test.go b/backend/internal/util/permissions_test.go index 542d2d3c..3e174627 100644 --- a/backend/internal/util/permissions_test.go +++ b/backend/internal/util/permissions_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "syscall" "testing" ) @@ -189,3 +190,47 @@ func TestMapSaveErrorCode_PermissionDeniedText(t *testing.T) { t.Fatalf("expected permissions_write_denied, got %q", code) } } + +func TestCheckPathPermissions_NullBytePath(t *testing.T) { + result := CheckPathPermissions("bad\x00path", "rw") + if result.ErrorCode != "permissions_invalid_path" { + t.Fatalf("expected permissions_invalid_path, got %q", result.ErrorCode) + } + if result.Writable { + t.Fatalf("expected writable=false for null-byte path") + } +} + +func TestCheckPathPermissions_SymlinkPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink test is environment-dependent on windows") + } + + tmpDir := t.TempDir() + target := filepath.Join(tmpDir, "target.txt") + if err := os.WriteFile(target, []byte("ok"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + link := filepath.Join(tmpDir, "target-link.txt") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink not available in this environment: %v", err) + } + + result := CheckPathPermissions(link, "rw") + if result.ErrorCode != "permissions_unsupported_type" { + t.Fatalf("expected permissions_unsupported_type, got %q", result.ErrorCode) + } + if result.Writable { + t.Fatalf("expected writable=false for symlink path") + } +} + +func TestMapSaveErrorCode_ReadOnlyFilesystem(t *testing.T) { + code, ok := MapSaveErrorCode(syscall.EROFS) + if !ok { + t.Fatalf("expected readonly filesystem to be recognized") + } + if code != "permissions_db_readonly" { + t.Fatalf("expected permissions_db_readonly, got %q", code) + } +} diff --git a/docs/issues/local_patch_report_dod_manual_checklist.md b/docs/issues/local_patch_report_dod_manual_checklist.md new file mode 100644 index 00000000..6668efe3 --- /dev/null +++ b/docs/issues/local_patch_report_dod_manual_checklist.md @@ -0,0 +1,68 @@ +--- +title: Manual Checklist - Local Patch Report DoD Ordering +status: Open +priority: High +assignee: QA +labels: testing, coverage, dod +--- + +# Goal +Validate that local patch-report workflow is executed in Definition of Done (DoD) order and produces required artifacts for handoff. + +# Preconditions +- Work from repository root: `/projects/Charon` +- Branch has local changes to evaluate +- Docker E2E environment is healthy + +# Manual Checklist + +## 1) E2E First (Mandatory) +- [ ] Run: `cd /projects/Charon && npx playwright test --project=firefox` +- [ ] Confirm run completes without blocking failures +- [ ] Record run timestamp for ordering evidence + +## 2) Local Patch Report Preflight (Before Unit Coverage) +- [ ] Run: `cd /projects/Charon && bash scripts/local-patch-report.sh` +- [ ] Confirm artifacts exist: + - [ ] `test-results/local-patch-report.md` + - [ ] `test-results/local-patch-report.json` +- [ ] Confirm JSON includes: + - [ ] `baseline = origin/main...HEAD` + - [ ] `mode = warn` + - [ ] `overall`, `backend`, `frontend` coverage blocks + - [ ] `files_needing_coverage` list + +## 3) Backend Coverage Run +- [ ] Run: `cd /projects/Charon/backend && go test ./... -coverprofile=coverage.txt` +- [ ] Confirm `backend/coverage.txt` exists and is current +- [ ] Confirm run exit code is 0 + +## 4) Frontend Coverage Run +- [ ] Run: `cd /projects/Charon/frontend && npm run test:coverage` +- [ ] Confirm `frontend/coverage/lcov.info` exists and is current +- [ ] Confirm run exit code is 0 + +## 5) Refresh Local Patch Report After Coverage Updates +- [ ] Run again: `cd /projects/Charon && bash scripts/local-patch-report.sh` +- [ ] Confirm report reflects latest coverage inputs and updated file gaps + +## 6) DoD Ordering Verification (Practical) +- [ ] Verify command history/logs show this order: + 1. E2E + 2. Local patch report preflight + 3. Backend/frontend coverage runs + 4. Local patch report refresh +- [ ] Verify no skipped step in the sequence + +## 7) Handoff Artifact Verification +- [ ] Verify required handoff artifacts are present: + - [ ] `test-results/local-patch-report.md` + - [ ] `test-results/local-patch-report.json` + - [ ] `backend/coverage.txt` + - [ ] `frontend/coverage/lcov.info` +- [ ] Verify latest QA report includes current patch-coverage summary section + +# Pass Criteria +- All checklist items complete in order. +- Local patch report artifacts are generated and current. +- Any below-threshold overall patch coverage is explicitly documented as warn-mode during rollout. diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index eb1fdfb1..75089801 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -54,6 +54,7 @@ Both artifacts are mandatory per run. Missing either artifact is a failed local - Local patch report does not fail DoD on low patch coverage during initial rollout. - Local runner emits warnings (stdout + markdown/json status fields) when thresholds are not met. - DoD requires the report to run and artifacts to exist, even in warning mode. +- Execution and final merge checks in this plan follow this same warn-mode policy during rollout. ### Threshold Defaults and Source Precedence @@ -121,6 +122,10 @@ Minimum JSON fields: - `patch_coverage_pct` - `status` (`pass` | `warn`) - `backend` and `frontend` objects with same coverage counters and status +- `files_needing_coverage` (required array for execution baselines), where each item includes at minimum: + - `path` + - `uncovered_changed_lines` + - `patch_coverage_pct` - `artifacts` with emitted file paths Minimum Markdown sections: @@ -243,3 +248,198 @@ jq -r '.baseline' test-results/local-patch-report.json - [ ] Concrete script + task wiring tasks are present and executable. - [ ] Validation commands are present and reproducible. - [ ] Stale unrelated placeholder gates are removed from this active spec. + +## 10) Concrete Execution Plan — Patch Gap Closure (PR Merge Objective) + +Single-scope objective: close current patch gaps for this PR merge by adding targeted tests and iterating local patch reports until changed-line coverage is merge-ready under DoD. + +### Authoritative Gap Baseline (2026-02-17) + +Use this list as the only planning baseline for this execution cycle: + +- `backend/cmd/localpatchreport/main.go`: 0%, 200 uncovered changed lines, ranges `46-59`, `61-73`, `75-79`, `81-85`, `87-96`, `98-123`, `125-156`, `158-165`, `167-172`, `175-179`, `182-187`, `190-198`, `201-207`, `210-219`, `222-254`, `257-264`, `267-269` +- `frontend/src/pages/UsersPage.tsx`: 30.8%, 9 uncovered (`152-160`) +- `frontend/src/pages/CrowdSecConfig.tsx`: 36.8%, 12 uncovered (`975-977`, `1220`, `1248-1249`, `1281-1282`, `1316`, `1324-1325`, `1335`) +- `frontend/src/pages/DNSProviders.tsx`: 70.6%, 10 uncovered +- `frontend/src/pages/AuditLogs.tsx`: 75.0%, 1 uncovered +- `frontend/src/components/ProxyHostForm.tsx`: 75.5%, 12 uncovered +- `backend/internal/api/middleware/auth.go`: 86.4%, 3 uncovered +- `frontend/src/pages/Notifications.tsx`: 88.9%, 3 uncovered +- `backend/internal/cerberus/rate_limit.go`: 91.9%, 12 uncovered + +### DoD Entry Gate (Mandatory Before Phase 1) + +All execution phases are blocked until this gate is completed in order: + +1) E2E first: + +```bash +cd /projects/Charon && npx playwright test --project=firefox +``` + +2) Local patch preflight (baseline refresh trigger): + +```bash +cd /projects/Charon && bash scripts/local-patch-report.sh +``` + +3) Baseline refresh checkpoint (must pass before phase execution): + +```bash +cd /projects/Charon && jq -r '.files_needing_coverage[].path' test-results/local-patch-report.json | sort > /tmp/charon-baseline-files.txt +cd /projects/Charon && while read -r f; do git diff --name-only origin/main...HEAD -- "$f" | grep -qx "$f" || echo "baseline file missing from current diff: $f"; done < /tmp/charon-baseline-files.txt +``` + +4) If checkpoint output is non-empty, refresh this baseline list to match the latest `test-results/local-patch-report.json` before starting Phase 1. + +### Ordered Phases (Highest Impact First) + +#### Phase 1 — Backend Local Patch Report CLI (Highest Delta) + +Targets: +- `backend/cmd/localpatchreport/main.go` (all listed uncovered ranges) + +Suggested test file: +- `backend/cmd/localpatchreport/main_test.go` + +Test focus: +- argument parsing and mode selection +- coverage input validation paths +- baseline/diff resolution flow +- report generation branches (markdown/json) +- warning/error branches for missing inputs and malformed coverage + +Pass criteria: +- maximize reduction of uncovered changed lines in `backend/cmd/localpatchreport/main.go` from the `200` baseline, with priority on highest-impact uncovered ranges and no new uncovered changed lines introduced +- backend targeted test command passes + +Targeted test command: + +```bash +cd /projects/Charon/backend && go test ./cmd/localpatchreport -coverprofile=coverage.txt +``` + +#### Phase 2 — Frontend Lowest-Coverage, Highest-Uncovered Pages + +Targets: +- `frontend/src/pages/CrowdSecConfig.tsx` (`975-977`, `1220`, `1248-1249`, `1281-1282`, `1316`, `1324-1325`, `1335`) +- `frontend/src/pages/UsersPage.tsx` (`152-160`) +- `frontend/src/pages/DNSProviders.tsx` (10 uncovered changed lines) + +Suggested test files: +- `frontend/src/pages/__tests__/CrowdSecConfig.patch-gap.test.tsx` +- `frontend/src/pages/__tests__/UsersPage.patch-gap.test.tsx` +- `frontend/src/pages/__tests__/DNSProviders.patch-gap.test.tsx` + +Test focus: +- branch/error-state rendering tied to uncovered lines +- conditional action handlers and callback guards +- edge-case interaction states not hit by existing tests + +Pass criteria: +- maximize reduction of changed-line gaps for the three targets, prioritize highest-impact uncovered lines first, and avoid introducing new uncovered changed lines +- frontend targeted test command passes + +Targeted test command: + +```bash +cd /projects/Charon/frontend && npm run test:coverage -- src/pages/__tests__/CrowdSecConfig.patch-gap.test.tsx src/pages/__tests__/UsersPage.patch-gap.test.tsx src/pages/__tests__/DNSProviders.patch-gap.test.tsx +``` + +#### Phase 3 — Backend Residual Middleware/Security Gaps + +Targets: +- `backend/internal/api/middleware/auth.go` (3 uncovered changed lines) +- `backend/internal/cerberus/rate_limit.go` (12 uncovered changed lines) + +Suggested test targets/files: +- extend `backend/internal/api/middleware/auth_test.go` +- extend `backend/internal/cerberus/rate_limit_test.go` + +Test focus: +- auth middleware edge branches (token/context failure paths) +- rate-limit boundary and deny/allow branch coverage + +Pass criteria: +- maximize reduction of changed-line gaps for both backend files, prioritize highest-impact uncovered lines first, and avoid introducing new uncovered changed lines +- backend targeted test command passes + +Targeted test command: + +```bash +cd /projects/Charon/backend && go test ./internal/api/middleware ./internal/cerberus -coverprofile=coverage.txt +``` + +#### Phase 4 — Frontend Component + Residual Page Gaps + +Targets: +- `frontend/src/components/ProxyHostForm.tsx` (12 uncovered changed lines) +- `frontend/src/pages/AuditLogs.tsx` (1 uncovered changed line) +- `frontend/src/pages/Notifications.tsx` (3 uncovered changed lines) + +Suggested test files: +- `frontend/src/components/__tests__/ProxyHostForm.patch-gap.test.tsx` +- `frontend/src/pages/__tests__/AuditLogs.patch-gap.test.tsx` +- `frontend/src/pages/__tests__/Notifications.patch-gap.test.tsx` + +Test focus: +- form branch paths and validation fallbacks +- single-line residual branch in audit logs +- notification branch handling for low-frequency states + +Pass criteria: +- maximize reduction of changed-line gaps for all three targets, prioritize highest-impact uncovered lines first, and avoid introducing new uncovered changed lines +- frontend targeted test command passes + +Targeted test command: + +```bash +cd /projects/Charon/frontend && npm run test:coverage -- src/components/__tests__/ProxyHostForm.patch-gap.test.tsx src/pages/__tests__/AuditLogs.patch-gap.test.tsx src/pages/__tests__/Notifications.patch-gap.test.tsx +``` + +### Execution Commands + +Run from repository root unless stated otherwise. + +1) Backend coverage: + +```bash +cd backend && go test ./... -coverprofile=coverage.txt +``` + +2) Frontend coverage: + +```bash +cd frontend && npm run test:coverage +``` + +3) Local patch report iteration: + +```bash +bash scripts/local-patch-report.sh +``` + +4) Iteration loop (repeat until all target gaps are closed): + +```bash +cd backend && go test ./... -coverprofile=coverage.txt +cd /projects/Charon/frontend && npm run test:coverage +cd /projects/Charon && bash scripts/local-patch-report.sh +``` + +### Phase Completion Checks + +- After each phase, rerun `bash scripts/local-patch-report.sh` and confirm that only the next planned target set remains uncovered. +- Do not advance phases when a phase target still shows uncovered changed lines. + +### Final Merge-Ready Gate (DoD-Aligned, Warn-Mode Rollout) + +This PR is merge-ready only when all conditions are true: + +- local patch report runs in warn mode and required artifacts are generated +- practical merge objective: drive a significant reduction in authoritative baseline uncovered changed lines in this PR, prioritizing highest-impact files; `0` remains aspirational and is not a warn-mode merge blocker +- required artifacts exist and are current: + - `test-results/local-patch-report.md` + - `test-results/local-patch-report.json` +- backend and frontend coverage commands complete successfully +- DoD checks remain satisfied (E2E first, local patch report preflight, required security/coverage/type/build validations) diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 2d352fcd..5e98e3e2 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -11,6 +11,120 @@ summary: "Definition of Done validation results, including coverage, security sc post_date: "2026-02-10" --- +## Current Branch QA/Security Audit - 2026-02-17 + +### Patch Coverage Push Handoff (Latest Local Report) + +- Source: `test-results/local-patch-report.json` +- Generated: `2026-02-17T18:40:46Z` +- Mode: **warn** +- Summary: + - Overall patch coverage: **85.4%** (threshold 90%) → **warn** + - Backend patch coverage: **85.1%** (threshold 85%) → **pass** + - Frontend patch coverage: **91.0%** (threshold 85%) → **pass** +- Current warn-mode trigger: + - Overall is below threshold by **4.6 points**; rollout remains non-blocking while artifacts are still required. +- Key files still needing patch coverage (highest handoff priority): + - `backend/internal/services/mail_service.go` — 20.8% patch coverage, 19 uncovered changed lines + - `frontend/src/pages/UsersPage.tsx` — 30.8% patch coverage, 9 uncovered changed lines + - `backend/internal/crowdsec/hub_sync.go` — 37.5% patch coverage, 10 uncovered changed lines + - `backend/internal/services/security_service.go` — 46.4% patch coverage, 15 uncovered changed lines + - `backend/internal/api/handlers/backup_handler.go` — 53.6% patch coverage, 26 uncovered changed lines + - `backend/internal/api/handlers/import_handler.go` — 67.5% patch coverage, 26 uncovered changed lines + - `backend/internal/api/handlers/settings_handler.go` — 73.6% patch coverage, 24 uncovered changed lines + - `backend/internal/util/permissions.go` — 74.4% patch coverage, 34 uncovered changed lines + +### 1) E2E Ordering Requirement and Evidence + +- Status: **FAIL (missing current-cycle evidence)** +- Requirement: E2E must run before unit coverage and local patch preflight. +- Evidence found this cycle: + - Local patch preflight was run (`bash scripts/local-patch-report.sh`). + - No fresh Playwright execution artifact/report was found for this cycle before the preflight. +- Conclusion: Ordering proof is not satisfied for this audit cycle. + +### 2) Local Patch Preflight Artifacts (Presence + Validity) + +- Status: **PASS (warn-mode valid)** +- Artifacts present: + - `test-results/local-patch-report.md` + - `test-results/local-patch-report.json` +- Generated: `2026-02-17T18:40:46Z` +- Validity summary: + - Overall patch coverage: `85.4%` (**warn**, threshold `90%`) + - Backend patch coverage: `85.1%` (**pass**, threshold `85%`) + - Frontend patch coverage: `91.0%` (**pass**, threshold `85%`) + +### 3) Backend/Frontend Coverage Status and Thresholds + +- Threshold baseline: **85% minimum** (project QA/testing instructions) +- Backend coverage (current artifact `backend/coverage.txt`): **87.0%** → **PASS** +- Frontend line coverage (current artifact `frontend/coverage/lcov.info`): **74.70%** (`LH=1072`, `LF=1435`) → **FAIL** +- Note: Frontend coverage is currently below required threshold and blocks merge readiness. + +### 4) Fast Lint / Pre-commit Status + +- Command run: `pre-commit run --all-files` +- Status: **FAIL** +- Failing gate: `golangci-lint-fast` +- Current blocker categories from output: + - `errcheck`: unchecked `AddError` return values in tests + - `gosec`: test file permission/path safety findings + - `unused`: unused helper functions in tests + +### 5) Security Scans Required by DoD (This Cycle) + +- **Go vulnerability scan (`security-scan-go-vuln`)**: **PASS** (`No vulnerabilities found`) +- **GORM security scan (`security-scan-gorm --check`)**: **PASS** (0 critical/high/medium; info-only suggestions) +- **CodeQL (CI-aligned via skill)**: **PASS (non-blocking)** + - Go SARIF: `5` results (non-error/non-warning categories in this run) + - JavaScript SARIF: `0` results +- **Trivy filesystem scan (`security-scan-trivy`)**: **FAIL** + - Reported security issues, including Dockerfile misconfiguration (`DS-0002`: container user should not be root) +- **Docker image scan (`security-scan-docker-image`)**: **FAIL** + - Vulnerabilities found: `0 critical`, `1 high`, `9 medium`, `1 low` + - High finding: `GHSA-69x3-g4r3-p962` in `github.com/slackhq/nebula@v1.9.7` (fixed in `1.10.3`) + +### 6) Merge-Readiness Summary (Blockers + Exact Next Commands) + +- Merge readiness: **NOT READY** + +#### Explicit blockers + +1. Missing E2E-first ordering evidence for this cycle. +2. Frontend coverage below threshold (`74.70% < 85%`). +3. Fast pre-commit/lint failing (`golangci-lint-fast`). +4. Security scans failing: + - Trivy filesystem scan + - Docker image scan (1 High vulnerability) + +#### Exact next commands + +```bash +cd /projects/Charon && .github/skills/scripts/skill-runner.sh docker-rebuild-e2e +cd /projects/Charon && npx playwright test --project=firefox +cd /projects/Charon && bash scripts/local-patch-report.sh + +cd /projects/Charon && .github/skills/scripts/skill-runner.sh test-frontend-coverage +cd /projects/Charon && pre-commit run --all-files + +cd /projects/Charon && .github/skills/scripts/skill-runner.sh security-scan-trivy vuln,secret,misconfig json +cd /projects/Charon && .github/skills/scripts/skill-runner.sh security-scan-docker-image +cd /projects/Charon && .github/skills/scripts/skill-runner.sh security-scan-codeql all summary +``` + +#### Re-check command set after fixes + +```bash +cd /projects/Charon && npx playwright test --project=firefox +cd /projects/Charon && bash scripts/local-patch-report.sh +cd /projects/Charon && .github/skills/scripts/skill-runner.sh test-frontend-coverage +cd /projects/Charon && pre-commit run --all-files +cd /projects/Charon && .github/skills/scripts/skill-runner.sh security-scan-go-vuln +cd /projects/Charon && .github/skills/scripts/skill-runner.sh security-scan-gorm --check +cd /projects/Charon && .github/skills/scripts/skill-runner.sh security-scan-codeql all summary +``` + ## Validation Checklist - Phase 1 - E2E Tests: PASS (provided: notification tests now pass) diff --git a/frontend/src/components/__tests__/ProxyHostForm.test.tsx b/frontend/src/components/__tests__/ProxyHostForm.test.tsx index ed6878d7..60ad09f5 100644 --- a/frontend/src/components/__tests__/ProxyHostForm.test.tsx +++ b/frontend/src/components/__tests__/ProxyHostForm.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' -import { render, screen, waitFor, within } from '@testing-library/react' +import { render, screen, waitFor, within, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { act } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' @@ -731,6 +731,33 @@ describe('ProxyHostForm', () => { expect(blockExploitsCheckbox).toBeChecked() await userEvent.click(blockExploitsCheckbox) expect(blockExploitsCheckbox).not.toBeChecked() + + // Toggle HSTS Subdomains (default is true) + const hstsSubdomainsCheckbox = screen.getByLabelText('HSTS Subdomains') + expect(hstsSubdomainsCheckbox).toBeChecked() + await userEvent.click(hstsSubdomainsCheckbox) + expect(hstsSubdomainsCheckbox).not.toBeChecked() + }) + + it('submits updated hsts_subdomains flag', async () => { + await renderWithClientAct( + + ) + + await userEvent.type(screen.getByPlaceholderText('My Service'), 'HSTS Toggle') + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'hsts.existing.com') + await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.10') + + const hstsSubdomainsCheckbox = screen.getByLabelText('HSTS Subdomains') + await userEvent.click(hstsSubdomainsCheckbox) + + await userEvent.click(screen.getByText('Save')) + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({ + hsts_subdomains: false, + })) + }) }) }) @@ -897,6 +924,25 @@ describe('ProxyHostForm', () => { })) }) }) + + it('renders and selects non-preset security header profile options', async () => { + const { useSecurityHeaderProfiles } = await import('../../hooks/useSecurityHeaders') + vi.mocked(useSecurityHeaderProfiles).mockReturnValue({ + data: [ + { id: 100, name: 'Strict Profile', description: 'Very strict', security_score: 90, is_preset: true, preset_type: 'strict' }, + { id: 101, name: 'Custom Profile', description: 'Custom profile', security_score: 70, is_preset: false }, + ], + isLoading: false, + error: null, + } as unknown as ReturnType) + + renderWithClient( + + ) + + await selectComboboxOption(/Security Headers/i, 'Custom Profile (Score: 70/100)') + expect(screen.getByRole('combobox', { name: /Security Headers/i })).toHaveTextContent('Custom Profile') + }) }) describe('Edit Mode vs Create Mode', () => { @@ -1072,6 +1118,34 @@ describe('ProxyHostForm', () => { }) describe('Port Input Handling', () => { + it('shows required-port validation branch when submit is triggered with empty port', async () => { + await renderWithClientAct( + + ) + + await userEvent.type(screen.getByPlaceholderText('My Service'), 'Port Required') + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'required.existing.com') + await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100') + + const portInput = screen.getByLabelText(/^Port$/) as HTMLInputElement + await userEvent.clear(portInput) + + const setCustomValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'setCustomValidity') + const reportValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'reportValidity').mockReturnValue(false) + + const form = document.querySelector('form') as HTMLFormElement + fireEvent.submit(form) + + await waitFor(() => { + expect(setCustomValiditySpy).toHaveBeenCalledWith('Port is required') + expect(reportValiditySpy).toHaveBeenCalled() + expect(mockOnSubmit).not.toHaveBeenCalled() + }) + + setCustomValiditySpy.mockRestore() + reportValiditySpy.mockRestore() + }) + it('validates port number range', async () => { await renderWithClientAct( @@ -1092,6 +1166,87 @@ describe('ProxyHostForm', () => { expect(portInput).toBeInvalid() expect(mockOnSubmit).not.toHaveBeenCalled() }) + + it('shows out-of-range validation branch when submit is triggered with invalid port', async () => { + await renderWithClientAct( + + ) + + await userEvent.type(screen.getByPlaceholderText('My Service'), 'Port Range Branch') + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'range.existing.com') + await userEvent.type(screen.getByLabelText(/^Host$/), '192.168.1.100') + + const portInput = screen.getByLabelText(/^Port$/) as HTMLInputElement + await userEvent.clear(portInput) + await userEvent.type(portInput, '70000') + + const setCustomValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'setCustomValidity') + const reportValiditySpy = vi.spyOn(HTMLInputElement.prototype, 'reportValidity').mockReturnValue(false) + + const form = document.querySelector('form') as HTMLFormElement + fireEvent.submit(form) + + await waitFor(() => { + expect(setCustomValiditySpy).toHaveBeenCalledWith('Port must be between 1 and 65535') + expect(reportValiditySpy).toHaveBeenCalled() + expect(mockOnSubmit).not.toHaveBeenCalled() + }) + + setCustomValiditySpy.mockRestore() + reportValiditySpy.mockRestore() + }) + }) + + describe('Remote Server Container Mapping', () => { + it('allows selecting a remote docker source option', async () => { + await renderWithClientAct( + + ) + + await selectComboboxOption('Source', 'Local Docker Registry (localhost)') + + expect(screen.getByRole('combobox', { name: 'Source' })).toHaveTextContent('Local Docker Registry') + }) + + it('maps remote docker container to remote host and public port', async () => { + const { useDocker } = await import('../../hooks/useDocker') + vi.mocked(useDocker).mockReturnValue({ + containers: [ + { + id: 'remote-container-1', + names: ['remote-app'], + image: 'nginx:latest', + state: 'running', + status: 'Up 1 hour', + network: 'bridge', + ip: '172.18.0.10', + ports: [{ private_port: 80, public_port: 18080, type: 'tcp' }], + }, + ], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + renderWithClient( + + ) + + await userEvent.type(screen.getByLabelText(/^Name/), 'Remote Mapping') + await userEvent.type(screen.getByPlaceholderText('example.com, www.example.com'), 'remote.existing.com') + + await selectComboboxOption('Source', 'Local Docker Registry (localhost)') + await selectComboboxOption('Containers', 'remote-app (nginx:latest)') + + await userEvent.click(screen.getByText('Save')) + + await waitFor(() => { + expect(mockOnSubmit).toHaveBeenCalledWith(expect.objectContaining({ + forward_host: 'localhost', + forward_port: 18080, + })) + }) + }) }) describe('Host and Port Combination', () => { diff --git a/frontend/src/pages/__tests__/AuditLogs.test.tsx b/frontend/src/pages/__tests__/AuditLogs.test.tsx index f6af37fb..180a8f4c 100644 --- a/frontend/src/pages/__tests__/AuditLogs.test.tsx +++ b/frontend/src/pages/__tests__/AuditLogs.test.tsx @@ -374,6 +374,37 @@ describe('', () => { }) }) + it('falls back to raw details when details are not valid JSON', async () => { + const invalidDetailsLog = { + ...mockAuditLogs[0], + uuid: 'raw-details-log', + details: 'not-json', + } + + vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({ + logs: [invalidDetailsLog], + total: 1, + page: 1, + limit: 50, + }) + + renderWithProviders() + + await waitFor(() => { + expect(screen.getByText('admin@example.com')).toBeInTheDocument() + }) + + const row = screen.getByText('admin@example.com').closest('tr') + if (row) { + fireEvent.click(row) + } + + await waitFor(() => { + expect(screen.getByText('Audit Log Details')).toBeInTheDocument() + expect(screen.getByText(/"raw": "not-json"/)).toBeInTheDocument() + }) + }) + it('shows filter count badge', async () => { vi.spyOn(auditLogsApi, 'getAuditLogs').mockResolvedValue({ logs: [], diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx index a3322a4a..cd24415e 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.coverage.test.tsx @@ -1,5 +1,5 @@ import { AxiosError } from 'axios' -import { screen, waitFor, act, cleanup, within } from '@testing-library/react' +import { screen, waitFor, act, cleanup, within, fireEvent } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { QueryClient } from '@tanstack/react-query' import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -288,6 +288,45 @@ describe('CrowdSecConfig coverage', () => { await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('Backup: /tmp/backup.tar.gz')) }) + it('supports keyboard selection for preset cards (Enter and Space)', async () => { + vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({ + presets: [ + { + slug: CROWDSEC_PRESETS[0].slug, + title: CROWDSEC_PRESETS[0].title, + summary: CROWDSEC_PRESETS[0].description, + source: 'hub', + requires_hub: false, + available: true, + cached: false, + cache_key: 'cache-a', + }, + { + slug: CROWDSEC_PRESETS[1].slug, + title: CROWDSEC_PRESETS[1].title, + summary: CROWDSEC_PRESETS[1].description, + source: 'hub', + requires_hub: false, + available: true, + cached: false, + cache_key: 'cache-b', + }, + ], + }) + + await renderPage() + + const firstCard = await screen.findByRole('button', { name: new RegExp(CROWDSEC_PRESETS[0].title, 'i') }) + const secondCard = await screen.findByRole('button', { name: new RegExp(CROWDSEC_PRESETS[1].title, 'i') }) + + firstCard.focus() + await userEvent.keyboard('{Enter}') + secondCard.focus() + await userEvent.keyboard(' ') + + await waitFor(() => expect(presetsApi.pullCrowdsecPreset).toHaveBeenCalledTimes(2)) + }) + it('falls back to local apply on 501 and covers validation/hub/offline branches', async () => { vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({}) vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValueOnce(axiosError(501, 'not implemented')) @@ -460,6 +499,79 @@ describe('CrowdSecConfig coverage', () => { await waitFor(() => expect(toast.error).toHaveBeenCalledWith('unban fail')) }) + it('supports ban modal click and keyboard interactions', async () => { + await renderPage() + + await userEvent.click(screen.getByRole('button', { name: /Ban IP/ })) + expect(await screen.findByText('Ban IP Address')).toBeInTheDocument() + + const banDialog = screen.getByRole('dialog', { name: 'Ban IP Address' }) + const banOverlay = banDialog.parentElement?.querySelector('[class*="bg-black/60"]') as HTMLElement + fireEvent.click(banOverlay) + await waitFor(() => expect(screen.queryByText('Ban IP Address')).not.toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /Ban IP/ })) + const modalContainer = screen.getByRole('dialog', { name: 'Ban IP Address' }) + const ipInput = within(modalContainer).getByPlaceholderText('192.168.1.100') + await userEvent.type(ipInput, '9.9.9.9') + + await userEvent.keyboard('{Control>}{Enter}{/Control}') + await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('9.9.9.9', '24h', '')) + + await userEvent.click(screen.getByRole('button', { name: /Ban IP/ })) + const secondModalContainer = screen.getByRole('dialog', { name: 'Ban IP Address' }) + const secondIpInput = within(secondModalContainer).getByPlaceholderText('192.168.1.100') + await userEvent.type(secondIpInput, '8.8.8.8') + await userEvent.keyboard('{Enter}') + await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('8.8.8.8', '24h', '')) + + await userEvent.click(screen.getByRole('button', { name: /Ban IP/ })) + const thirdModalContainer = screen.getByRole('dialog', { name: 'Ban IP Address' }) + const thirdIpInput = within(thirdModalContainer).getByPlaceholderText('192.168.1.100') + await userEvent.type(thirdIpInput, '8.8.8.8') + const reasonInput = within(thirdModalContainer).getByLabelText('Reason') + await userEvent.type(reasonInput, 'manual reason{Enter}') + await waitFor(() => expect(crowdsecApi.banIP).toHaveBeenCalledWith('8.8.8.8', '24h', 'manual reason')) + }) + + it('supports unban modal overlay, Escape, Enter, and cancel button', async () => { + vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({ + decisions: [ + { id: '1', ip: '7.7.7.7', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' }, + ], + }) + + await renderPage() + await userEvent.click(await screen.findByRole('button', { name: 'Unban' })) + expect(await screen.findByText('Confirm Unban')).toBeInTheDocument() + + const unbanDialog = screen.getByRole('dialog', { name: 'Confirm Unban' }) + const unbanOverlay = unbanDialog.parentElement?.querySelector('[class*="bg-black/60"]') as HTMLElement + fireEvent.click(unbanOverlay) + await waitFor(() => expect(screen.queryByText('Confirm Unban')).not.toBeInTheDocument()) + + await userEvent.click(await screen.findByRole('button', { name: 'Unban' })) + expect(await screen.findByText('Confirm Unban')).toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await waitFor(() => expect(screen.queryByText('Confirm Unban')).not.toBeInTheDocument()) + + await userEvent.click(await screen.findByRole('button', { name: 'Unban' })) + expect(await screen.findByText('Confirm Unban')).toBeInTheDocument() + await userEvent.keyboard('{Enter}') + await waitFor(() => expect(crowdsecApi.unbanIP).toHaveBeenCalledWith('7.7.7.7')) + + vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValueOnce({ + decisions: [ + { id: '1', ip: '7.7.7.7', reason: 'bot', duration: '24h', created_at: '2024-01-01T00:00:00Z', source: 'manual' }, + ], + }) + await renderPage() + await userEvent.click(await screen.findByRole('button', { name: 'Unban' })) + const confirmContainer = screen.getByRole('dialog', { name: 'Confirm Unban' }) + await userEvent.click(within(confirmContainer).getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByText('Confirm Unban')).not.toBeInTheDocument()) + }) + it('bans and unbans IPs with overlay messaging', async () => { vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [ diff --git a/frontend/src/pages/__tests__/DNSProviders.test.tsx b/frontend/src/pages/__tests__/DNSProviders.test.tsx index 41e42c25..1e38b9aa 100644 --- a/frontend/src/pages/__tests__/DNSProviders.test.tsx +++ b/frontend/src/pages/__tests__/DNSProviders.test.tsx @@ -6,6 +6,7 @@ import DNSProviders from '../DNSProviders' import { renderWithQueryClient } from '../../test-utils/renderWithQueryClient' import { useDNSProviders, useDNSProviderMutations, type DNSProvider } from '../../hooks/useDNSProviders' import { getChallenge } from '../../api/manualChallenge' +import { toast } from '../../utils/toast' vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -55,8 +56,20 @@ vi.mock('../../components/DNSProviderForm', () => ({ })) vi.mock('../../components/dns-providers', () => ({ - ManualDNSChallenge: ({ challenge }: { challenge: { fqdn: string } }) => ( -
{challenge.fqdn}
+ ManualDNSChallenge: ({ + challenge, + onComplete, + onCancel, + }: { + challenge: { fqdn: string } + onComplete: () => void + onCancel: () => void + }) => ( +
+
{challenge.fqdn}
+ + +
), })) @@ -140,5 +153,59 @@ describe('DNSProviders page state behavior', () => { await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' })) expect(await screen.findByTestId('manual-dns-challenge')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'complete-manual' })) + await waitFor(() => { + expect(getChallenge).toHaveBeenCalledTimes(2) + }) + + await user.click(screen.getByRole('button', { name: 'cancel-manual' })) + await waitFor(() => { + expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument() + }) + }) + + it('re-evaluates manual challenge visibility after completion refresh', async () => { + vi.mocked(getChallenge) + .mockResolvedValueOnce({ + id: 'active', + status: 'pending', + fqdn: '_acme-challenge.example.com', + value: 'token', + ttl: 300, + created_at: '2026-02-15T00:00:00Z', + expires_at: '2026-02-15T00:10:00Z', + dns_propagated: false, + }) + .mockRejectedValueOnce(new Error('challenge missing after refresh')) + + const user = userEvent.setup() + renderWithQueryClient() + + await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' })) + expect(await screen.findByTestId('manual-dns-challenge')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'complete-manual' })) + + await waitFor(() => { + expect(getChallenge).toHaveBeenCalledTimes(2) + expect(screen.queryByTestId('manual-dns-challenge')).not.toBeInTheDocument() + }) + }) + + it('shows no provider toast when manual challenge is requested without providers', async () => { + vi.mocked(useDNSProviders).mockReturnValue({ + data: [], + isLoading: false, + refetch: vi.fn(), + } as unknown as ReturnType) + + const user = userEvent.setup() + renderWithQueryClient() + + await user.click(screen.getByRole('button', { name: 'dnsProvider.manual.title' })) + + expect(toast.error).toHaveBeenCalledWith('dnsProviders.noProviders') + expect(getChallenge).not.toHaveBeenCalled() }) }) diff --git a/frontend/src/pages/__tests__/Notifications.test.tsx b/frontend/src/pages/__tests__/Notifications.test.tsx index cc54451e..6df1234c 100644 --- a/frontend/src/pages/__tests__/Notifications.test.tsx +++ b/frontend/src/pages/__tests__/Notifications.test.tsx @@ -198,4 +198,102 @@ describe('Notifications', () => { const resetNotifyProxyHosts = screen.getByTestId('notify-proxy-hosts') as HTMLInputElement expect(resetNotifyProxyHosts.checked).toBe(true) }) + + it('renders external template loading and rows when templates are present', async () => { + const template = { + id: 'template-1', + name: 'Ops Payload', + description: 'Template for ops alerts', + template: 'custom' as const, + config: '{"text":"{{.Message}}"}', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + } + + vi.mocked(notificationsApi.getExternalTemplates).mockReturnValue(new Promise(() => {})) + const { unmount } = renderWithQueryClient() + await userEvent.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' })) + expect(screen.getByTestId('external-templates-loading')).toBeInTheDocument() + unmount() + + vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([template]) + renderWithQueryClient() + await userEvent.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' })) + + expect(await screen.findByTestId('external-template-row-template-1')).toBeInTheDocument() + expect(screen.getByText('Ops Payload')).toBeInTheDocument() + }) + + it('opens external template editor and deletes template on confirm', async () => { + const template = { + id: 'template-2', + name: 'Security Payload', + description: 'Template for security alerts', + template: 'custom' as const, + config: '{"text":"{{.Message}}"}', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + } + + vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([template]) + vi.mocked(notificationsApi.deleteExternalTemplate).mockResolvedValue(undefined) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + + const user = userEvent.setup() + renderWithQueryClient() + await user.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' })) + + const row = await screen.findByTestId('external-template-row-template-2') + expect(row).toBeInTheDocument() + + await user.click(screen.getByTestId('external-template-edit-template-2')) + await waitFor(() => { + expect((screen.getByTestId('template-name') as HTMLInputElement).value).toBe('Security Payload') + }) + + await user.click(screen.getByTestId('external-template-delete-template-2')) + await waitFor(() => { + expect(confirmSpy).toHaveBeenCalled() + expect(notificationsApi.deleteExternalTemplate).toHaveBeenCalledWith('template-2') + }) + + confirmSpy.mockRestore() + }) + + it('renders external template action buttons and skips delete when confirm is cancelled', async () => { + const template = { + id: 'template-cancel', + name: 'Cancel Delete Template', + description: 'Template used for cancel delete branch', + template: 'custom' as const, + config: '{"text":"{{.Message}}"}', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + } + + vi.mocked(notificationsApi.getExternalTemplates).mockResolvedValue([template]) + vi.mocked(notificationsApi.deleteExternalTemplate).mockResolvedValue(undefined) + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false) + + const user = userEvent.setup() + renderWithQueryClient() + await user.click(await screen.findByRole('button', { name: 'notificationProviders.manageTemplates' })) + + expect(await screen.findByTestId('external-template-row-template-cancel')).toBeInTheDocument() + + const editButton = screen.getByTestId('external-template-edit-template-cancel') + const deleteButton = screen.getByTestId('external-template-delete-template-cancel') + + await user.click(editButton) + await waitFor(() => { + expect((screen.getByTestId('template-name') as HTMLInputElement).value).toBe('Cancel Delete Template') + }) + + await user.click(deleteButton) + + expect(confirmSpy).toHaveBeenCalled() + expect(notificationsApi.deleteExternalTemplate).not.toHaveBeenCalled() + + confirmSpy.mockRestore() + }) }) diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index adc67560..1fe5b284 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -363,6 +363,113 @@ describe('UsersPage', () => { } }) + it('uses textarea fallback copy when clipboard API fails', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.inviteUser).mockResolvedValue({ + id: 6, + uuid: 'invitee-fallback', + email: 'fallback@example.com', + role: 'user', + invite_token: 'token-fallback', + invite_url: 'https://charon.example.com/accept-invite?token=token-fallback', + email_sent: false, + expires_at: '2025-01-01T00:00:00Z', + }) + + const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { + get: () => undefined, + configurable: true, + }) + + const appendSpy = vi.spyOn(document.body, 'appendChild') + const removeSpy = vi.spyOn(document.body, 'removeChild') + Object.defineProperty(document, 'execCommand', { + value: vi.fn(), + configurable: true, + writable: true, + }) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: /Invite User/i })) + await user.type(screen.getByPlaceholderText('user@example.com'), 'fallback@example.com') + await user.click(screen.getByRole('button', { name: /^Send Invite$/i })) + + await screen.findByDisplayValue(/accept-invite\?token=token-fallback/) + await user.click(screen.getByRole('button', { name: /copy invite link/i })) + + await waitFor(() => { + expect(appendSpy).toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard') + }) + + appendSpy.mockRestore() + removeSpy.mockRestore() + + if (originalDescriptor) { + Object.defineProperty(navigator, 'clipboard', originalDescriptor) + } else { + delete (navigator as unknown as { clipboard?: unknown }).clipboard + } + }) + + it('uses textarea fallback copy when clipboard writeText rejects', async () => { + vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) + vi.mocked(usersApi.inviteUser).mockResolvedValue({ + id: 7, + uuid: 'invitee-reject', + email: 'reject@example.com', + role: 'user', + invite_token: 'token-reject', + invite_url: 'https://charon.example.com/accept-invite?token=token-reject', + email_sent: false, + expires_at: '2025-01-01T00:00:00Z', + }) + + const writeText = vi.fn().mockRejectedValue(new Error('clipboard denied')) + const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard') + Object.defineProperty(navigator, 'clipboard', { + get: () => ({ writeText }), + configurable: true, + }) + + const appendSpy = vi.spyOn(document.body, 'appendChild') + const removeSpy = vi.spyOn(document.body, 'removeChild') + Object.defineProperty(document, 'execCommand', { + value: vi.fn().mockReturnValue(true), + configurable: true, + writable: true, + }) + + renderWithQueryClient() + + const user = userEvent.setup() + await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument()) + await user.click(screen.getByRole('button', { name: /Invite User/i })) + await user.type(screen.getByPlaceholderText('user@example.com'), 'reject@example.com') + await user.click(screen.getByRole('button', { name: /^Send Invite$/i })) + + await screen.findByDisplayValue(/accept-invite\?token=token-reject/) + await user.click(screen.getByRole('button', { name: /copy invite link/i })) + + await waitFor(() => { + expect(appendSpy).toHaveBeenCalled() + expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard') + }) + + appendSpy.mockRestore() + removeSpy.mockRestore() + + if (originalDescriptor) { + Object.defineProperty(navigator, 'clipboard', originalDescriptor) + } else { + delete (navigator as unknown as { clipboard?: unknown }).clipboard + } + }) + describe('URL Preview in InviteModal', () => { afterEach(() => { vi.useRealTimers()