package handlers import ( "bytes" "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 { t.Fatalf("failed to connect database: %v", err) } return db } // MockImporterService implements handlers.ImporterService type MockImporterService struct { mock.Mock } func (m *MockImporterService) NormalizeCaddyfile(content string) (string, error) { args := m.Called(content) return args.String(0), args.Error(1) } func (m *MockImporterService) ParseCaddyfile(path string) ([]byte, error) { args := m.Called(path) return args.Get(0).([]byte), args.Error(1) } func (m *MockImporterService) ImportFile(path string) (*caddy.ImportResult, error) { args := m.Called(path) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*caddy.ImportResult), args.Error(1) } func (m *MockImporterService) ExtractHosts(caddyJSON []byte) (*caddy.ImportResult, error) { args := m.Called(caddyJSON) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*caddy.ImportResult), args.Error(1) } func (m *MockImporterService) ValidateCaddyBinary() error { args := m.Called() return args.Error(0) } // TestUploadMulti_EmptyList covers the manual check for len(Files) == 0 func TestUploadMulti_EmptyList(t *testing.T) { gin.SetMode(gin.TestMode) db := setupImportCoverageTestDB(t) mockSvc := new(MockImporterService) h := NewImportHandler(db, "caddy", "/tmp", "/tmp") 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) // Create JSON with empty files list req := map[string]interface{}{ "files": []interface{}{}, } 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) // Matched Gin validation error assert.Contains(t, w.Body.String(), "Error:Field validation for 'Files' failed on the 'min' tag") } // TestUploadMulti_FileServerDetected covers the logic where parsable routes trigger a warning // because they contain file_server but no valid reverse_proxy hosts func TestUploadMulti_FileServerDetected(t *testing.T) { gin.SetMode(gin.TestMode) db := setupImportCoverageTestDB(t) mockSvc := new(MockImporterService) // Return a result that has empty Forward host/port (not importable) // AND contains a "file_server" warning mockResult := &caddy.ImportResult{ Hosts: []caddy.ParsedHost{ { DomainNames: "files.example.com", Warnings: []string{"directive 'file_server' detected"}, }, }, } mockSvc.On("ImportFile", mock.AnythingOfType("string")).Return(mockResult, nil) h := NewImportHandler(db, "caddy", "/tmp", "/tmp") h.importerservice = mockSvc // Override import dir to temp h.importDir = 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": "files.example.com { file_server }", }, }, } 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(), "File server directives are not supported") } // TestUploadMulti_NoSitesParsed covers successfull parsing but 0 result hosts func TestUploadMulti_NoSitesParsed(t *testing.T) { gin.SetMode(gin.TestMode) db := setupImportCoverageTestDB(t) mockSvc := new(MockImporterService) // Return empty result mockResult := &caddy.ImportResult{ Hosts: []caddy.ParsedHost{}, } mockSvc.On("ImportFile", mock.AnythingOfType("string")).Return(mockResult, nil) h := NewImportHandler(db, "caddy", "/tmp", "/tmp") h.importerservice = mockSvc h.importDir = 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": "# just a comment", }, }, } 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(), "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) wCancelMissing := httptest.NewRecorder() reqCancelMissing, _ := http.NewRequest(http.MethodDelete, "/api/v1/import/cancel", http.NoBody) r.ServeHTTP(wCancelMissing, reqCancelMissing) assert.Equal(t, http.StatusBadRequest, wCancelMissing.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") }