package handlers import ( "bytes" "encoding/json" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "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" "github.com/stretchr/testify/require" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // mockProxyHostService implements ProxyHostServiceInterface for testing type mockProxyHostService struct { createErr error updateErr error listResult []models.ProxyHost listErr error } func (m *mockProxyHostService) Create(host *models.ProxyHost) error { return m.createErr } func (m *mockProxyHostService) Update(host *models.ProxyHost) error { return m.updateErr } func (m *mockProxyHostService) List() ([]models.ProxyHost, error) { return m.listResult, m.listErr } // setupTestDB creates a test database with required tables // mockImporter for testing import operations type mockImporter struct { normalizeResult string normalizeErr error parseResult []byte parseErr error importResult *caddy.ImportResult importErr error } func (m *mockImporter) NormalizeCaddyfile(content string) (string, error) { if m.normalizeErr != nil { return "", m.normalizeErr } if m.normalizeResult != "" { return m.normalizeResult, nil } return content, nil } func (m *mockImporter) ParseCaddyfile(path string) ([]byte, error) { if m.parseErr != nil { return nil, m.parseErr } return m.parseResult, nil } func (m *mockImporter) ImportFile(path string) (*caddy.ImportResult, error) { if m.importErr != nil { return nil, m.importErr } return m.importResult, nil } func setupImportTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) err = db.AutoMigrate(&models.ImportSession{}, &models.ProxyHost{}) require.NoError(t, err) return db } // setupTestHandler creates a test handler with mocks func setupTestHandler(t *testing.T, db *gorm.DB) (*ImportHandler, *mockProxyHostService, *mockImporter) { t.Helper() tmpDir := t.TempDir() mockSvc := &mockProxyHostService{} handler := &ImportHandler{ db: db, proxyHostSvc: mockSvc, importerservice: nil, // Will be set via mock importDir: tmpDir, mountPath: "", } mockImport := &mockImporter{} return handler, mockSvc, mockImport } func addAdminMiddleware(router *gin.Engine) { router.Use(func(c *gin.Context) { setAdminContext(c) c.Next() }) } func TestImportHandler_GetStatus_MountCommittedUnchanged(t *testing.T) { t.Parallel() testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { mountDir := t.TempDir() mountPath := filepath.Join(mountDir, "mounted.caddyfile") require.NoError(t, os.WriteFile(mountPath, []byte("example.com { respond \"ok\" }"), 0o600)) committedAt := time.Now() require.NoError(t, tx.Create(&models.ImportSession{ UUID: "committed-1", SourceFile: mountPath, Status: "committed", CommittedAt: &committedAt, }).Error) require.NoError(t, os.Chtimes(mountPath, committedAt.Add(-1*time.Minute), committedAt.Add(-1*time.Minute))) handler, _, _ := setupTestHandler(t, tx) handler.mountPath = mountPath gin.SetMode(gin.TestMode) router := gin.New() addAdminMiddleware(router) handler.RegisterRoutes(router.Group("/api/v1")) req := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, false, body["has_pending"]) }) } func TestImportHandler_GetStatus_MountModifiedAfterCommit(t *testing.T) { t.Parallel() testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { mountDir := t.TempDir() mountPath := filepath.Join(mountDir, "mounted.caddyfile") require.NoError(t, os.WriteFile(mountPath, []byte("example.com { respond \"ok\" }"), 0o600)) committedAt := time.Now().Add(-10 * time.Minute) require.NoError(t, tx.Create(&models.ImportSession{ UUID: "committed-2", SourceFile: mountPath, Status: "committed", CommittedAt: &committedAt, }).Error) require.NoError(t, os.Chtimes(mountPath, time.Now(), time.Now())) handler, _, _ := setupTestHandler(t, tx) handler.mountPath = mountPath gin.SetMode(gin.TestMode) router := gin.New() addAdminMiddleware(router) handler.RegisterRoutes(router.Group("/api/v1")) req := httptest.NewRequest(http.MethodGet, "/api/v1/import/status", http.NoBody) w := httptest.NewRecorder() router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) var body map[string]any require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) assert.Equal(t, true, body["has_pending"]) }) } // TestUpload_NormalizationSuccess verifies single-line Caddyfile formatting func TestUpload_NormalizationSuccess(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, mockImport := setupTestHandler(t, tx) // Mock normalized output (multi-line format) normalizedContent := "example.com {\n\trespond \"OK\" 200\n}\n" mockImport.normalizeResult = normalizedContent mockImport.importResult = &caddy.ImportResult{ Hosts: []caddy.ParsedHost{ { DomainNames: "example.com", ForwardScheme: "http", ForwardHost: "localhost", ForwardPort: 8080, }, }, } // Set the mock importer handler.importerservice = &mockImporterAdapter{mockImport} // Create request with single-line Caddyfile singleLineContent := "example.com { respond \"OK\" 200 }" reqBody := map[string]string{ "content": singleLineContent, "filename": "test.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) assert.Equal(t, http.StatusOK, w.Code) // Verify normalized content was written to file var response map[string]any err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) session, ok := response["session"].(map[string]any) require.True(t, ok) require.NotEmpty(t, session["id"]) }) } // TestUpload_NormalizationFailure verifies Caddy fmt failure handling func TestUpload_NormalizationFailure(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, mockImport := setupTestHandler(t, tx) // Mock normalization failure (caddy fmt error) mockImport.normalizeErr = fmt.Errorf("caddy fmt failed: invalid syntax") mockImport.importResult = &caddy.ImportResult{ Hosts: []caddy.ParsedHost{ { DomainNames: "example.com", ForwardScheme: "http", ForwardHost: "localhost", ForwardPort: 8080, }, }, } handler.importerservice = &mockImporterAdapter{mockImport} reqBody := map[string]string{ "content": "example.com { invalid syntax", "filename": "test.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) // Should still succeed (logs warning, uses original content) assert.Equal(t, http.StatusOK, w.Code) }) } // TestUpload_PathTraversalBlocked verifies path traversal protection func TestUpload_PathTraversalBlocked(t *testing.T) { testCases := []struct { name string filename string }{ {"parent traversal", "../../../etc/passwd"}, {"absolute path", "/etc/passwd"}, {"unicode confusable", "..÷..÷etc÷passwd"}, // U+2215 {"encoded null byte", "file%00.txt"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, mockImport := setupTestHandler(t, tx) mockImport.importResult = &caddy.ImportResult{Hosts: []caddy.ParsedHost{}} handler.importerservice = &mockImporterAdapter{mockImport} reqBody := map[string]string{ "content": "test content", "filename": tc.filename, } 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) // Traversal should be handled by safeJoin, resulting in error or sanitized path // The response code depends on whether the sanitized path is valid assert.NotEqual(t, http.StatusInternalServerError, w.Code, "Should handle traversal gracefully") }) }) } } // TestUploadMulti_ArchiveExtraction verifies valid tar.gz with multiple files func TestUploadMulti_ArchiveExtraction(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: "site1.example.com", ForwardHost: "localhost", ForwardPort: 8001}, {DomainNames: "site2.example.com", ForwardHost: "localhost", ForwardPort: 8002}, }, } handler.importerservice = &mockImporterAdapter{mockImport} reqBody := map[string]any{ "files": []map[string]string{ {"filename": "Caddyfile", "content": "import sites/*"}, {"filename": "sites/site1.caddyfile", "content": "site1.example.com { reverse_proxy localhost:8001 }"}, {"filename": "sites/site2.caddyfile", "content": "site2.example.com { reverse_proxy localhost:8002 }"}, }, } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/import/upload-multi", 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) assert.Equal(t, http.StatusOK, w.Code) var response map[string]any err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) preview, ok := response["preview"].(map[string]any) require.True(t, ok) hosts, ok := preview["hosts"].([]any) require.True(t, ok) assert.Len(t, hosts, 2, "Should parse both site files") }) } // TestUploadMulti_ConflictDetection verifies duplicate filename handling func TestUploadMulti_ConflictDetection(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: "site1.example.com", ForwardHost: "localhost", ForwardPort: 8001}, }, } handler.importerservice = &mockImporterAdapter{mockImport} // Attempt to upload files with duplicate filenames reqBody := map[string]any{ "files": []map[string]string{ {"filename": "Caddyfile", "content": "import sites/*"}, {"filename": "site.caddyfile", "content": "site1.example.com { reverse_proxy localhost:8001 }"}, {"filename": "site.caddyfile", "content": "site2.example.com { reverse_proxy localhost:8002 }"}, }, } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/import/upload-multi", 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) // Should succeed - last write wins for duplicate filenames assert.Equal(t, http.StatusOK, w.Code) }) } // TestCommit_TransientToImport verifies session promotion success func TestCommit_TransientToImport(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, mockImport := setupTestHandler(t, tx) // Setup transient file tmpFile := filepath.Join(handler.importDir, "uploads", "test-session.caddyfile") require.NoError(t, os.MkdirAll(filepath.Dir(tmpFile), 0o755)) // #nosec G301 require.NoError(t, os.WriteFile(tmpFile, []byte("example.com { reverse_proxy localhost:8080 }"), 0o644)) // #nosec G306 mockImport.importResult = &caddy.ImportResult{ Hosts: []caddy.ParsedHost{ {DomainNames: "example.com", ForwardHost: "localhost", ForwardPort: 8080, ForwardScheme: "http"}, }, } handler.importerservice = &mockImporterAdapter{mockImport} reqBody := map[string]any{ "session_uuid": "test-session", "resolutions": map[string]string{"example.com": "create"}, "names": map[string]string{"example.com": "Example Site"}, } body, _ := json.Marshal(reqBody) 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) assert.Equal(t, http.StatusOK, w.Code) var response map[string]any err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(1), response["created"]) assert.Equal(t, float64(0), response["updated"]) }) } // TestCommit_RollbackOnError verifies ProxyHost service failure cleanup func TestCommit_RollbackOnError(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, mockSvc, mockImport := setupTestHandler(t, tx) // Force ProxyHost creation to fail mockSvc.createErr = fmt.Errorf("database error") tmpFile := filepath.Join(handler.importDir, "uploads", "test-session.caddyfile") require.NoError(t, os.MkdirAll(filepath.Dir(tmpFile), 0o755)) // #nosec G301 require.NoError(t, os.WriteFile(tmpFile, []byte("example.com { reverse_proxy localhost:8080 }"), 0o644)) // #nosec G306 mockImport.importResult = &caddy.ImportResult{ Hosts: []caddy.ParsedHost{ {DomainNames: "example.com", ForwardHost: "localhost", ForwardPort: 8080, ForwardScheme: "http"}, }, } handler.importerservice = &mockImporterAdapter{mockImport} reqBody := map[string]any{ "session_uuid": "test-session", "resolutions": map[string]string{"example.com": "create"}, } body, _ := json.Marshal(reqBody) 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) assert.Equal(t, http.StatusOK, w.Code) var response map[string]any err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(0), response["created"]) errors, ok := response["errors"].([]any) require.True(t, ok) assert.NotEmpty(t, errors, "Should report create errors") }) } // TestDetectImports_EmptyCaddyfile verifies graceful handling of no imports func TestDetectImports_EmptyCaddyfile(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, _ := setupTestHandler(t, tx) reqBody := map[string]string{ "content": "example.com { respond \"OK\" 200 }", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/import/detect-imports", 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) assert.Equal(t, http.StatusOK, w.Code) var response map[string]any err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.False(t, response["has_imports"].(bool)) imports, ok := response["imports"].([]any) require.True(t, ok) assert.Empty(t, imports) }) } // TestSafeJoin_ParentTraversal verifies .. handling func TestSafeJoin_ParentTraversal(t *testing.T) { baseDir := "/tmp/testbase" testCases := []struct { name string userPath string expectErr bool }{ {"simple parent", "..", true}, {"triple parent", "../../../", true}, {"nested parent", "a/../../../", true}, {"valid subdir", "uploads/file.txt", false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result, err := safeJoin(baseDir, tc.userPath) if tc.expectErr { assert.Error(t, err, "Should block traversal") assert.Equal(t, "", result) } else { assert.NoError(t, err, "Should allow safe path") assert.NotEmpty(t, result) } }) } } // TestSafeJoin_AbsolutePath verifies absolute path blocking func TestSafeJoin_AbsolutePath(t *testing.T) { baseDir := "/tmp/testbase" testCases := []struct { name string path string }{ {"unix absolute", "/etc/passwd"}, {"windows absolute", "C:\\Windows\\System32"}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result, err := safeJoin(baseDir, tc.path) assert.Error(t, err, "Should block absolute paths") assert.Equal(t, "", result) }) } } // TestSafeJoin_NullByte verifies null byte injection protection func TestSafeJoin_NullByte(t *testing.T) { baseDir := "/tmp/testbase" // Note: filepath.Clean automatically strips null bytes, so they won't cause // traversal but may need validation at higher levels result, err := safeJoin(baseDir, "file\x00.txt") // Should succeed (null byte removed by filepath.Clean) assert.NoError(t, err) assert.NotContains(t, result, "\x00") } // TestSafeJoin_UnicodeConfusable verifies Unicode confusable handling func TestSafeJoin_UnicodeConfusable(t *testing.T) { baseDir := "/tmp/testbase" // U+2215 (DIVISION SLASH) looks like "/" but isn't confusablePath := "..÷..÷etc" result, err := safeJoin(baseDir, confusablePath) // Should allow (confusable doesn't act as path separator) assert.NoError(t, err) assert.Contains(t, result, baseDir, "Should be under base directory") } // mockImporterAdapter adapts mockImporter to caddy.Importer interface type mockImporterAdapter struct { mock *mockImporter } func (a *mockImporterAdapter) NormalizeCaddyfile(content string) (string, error) { return a.mock.NormalizeCaddyfile(content) } func (a *mockImporterAdapter) ParseCaddyfile(path string) ([]byte, error) { return a.mock.ParseCaddyfile(path) } func (a *mockImporterAdapter) ImportFile(path string) (*caddy.ImportResult, error) { return a.mock.ImportFile(path) } func (a *mockImporterAdapter) ExtractHosts(caddyJSON []byte) (*caddy.ImportResult, error) { // Not used in handler tests return &caddy.ImportResult{}, nil } func (a *mockImporterAdapter) ValidateCaddyBinary() error { return nil } // ============================================ // Phase 1 Additional Coverage Tests // ============================================ // TestImportHandler_Upload_NullByteInjection verifies null byte handling in filenames func TestImportHandler_Upload_NullByteInjection(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, mockImport := setupTestHandler(t, tx) mockImport.importResult = &caddy.ImportResult{Hosts: []caddy.ParsedHost{}} handler.importerservice = &mockImporterAdapter{mockImport} reqBody := map[string]string{ "content": "test content", "filename": "file\x00.txt", } 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) // safeJoin strips null bytes via filepath.Clean, so upload should succeed // but the null byte should not be in the final path assert.True(t, w.Code == 200 || w.Code == 400, "Should handle null byte gracefully") }) } // TestImportHandler_DetectImports_MalformedCaddyfile verifies error handling for invalid syntax func TestImportHandler_DetectImports_MalformedFile(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, _ := setupTestHandler(t, tx) // Caddyfile with malformed syntax (missing closing brace) reqBody := map[string]string{ "content": "example.com {\n reverse_proxy localhost:8080\n# Missing closing brace", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/import/detect-imports", 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) // DetectImports only checks for "import" directives, doesn't validate syntax // So malformed files should still succeed if they don't contain import directives assert.Equal(t, http.StatusOK, w.Code, "DetectImports should succeed for malformed files") var response map[string]any err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.False(t, response["has_imports"].(bool), "Should detect no imports in malformed file") }) } // TestImportHandler_safeJoin_EdgeCases verifies path sanitization edge cases func TestImportHandler_safeJoin_EdgeCases(t *testing.T) { baseDir := "/tmp/testbase" testCases := []struct { name string userPath string expectErr bool desc string }{ { name: "empty path", userPath: "", expectErr: true, desc: "Empty paths should be rejected", }, { name: "dot path", userPath: ".", expectErr: true, desc: "Dot path should be rejected", }, { name: "double dot at start", userPath: "..", expectErr: true, desc: "Parent traversal should be rejected", }, { name: "double dot in middle", userPath: "subdir/../../../etc/passwd", expectErr: true, desc: "Nested traversal should be rejected", }, { name: "absolute unix path", userPath: "/etc/passwd", expectErr: true, desc: "Absolute paths should be rejected", }, { name: "Windows UNC path", userPath: "\\\\server\\share", expectErr: false, desc: "Windows UNC paths are cleaned but should not escape base", }, { name: "valid relative path", userPath: "uploads/session123.caddyfile", expectErr: false, desc: "Valid relative paths should succeed", }, { name: "path with spaces", userPath: "my folder/my file.txt", expectErr: false, desc: "Paths with spaces should be allowed", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result, err := safeJoin(baseDir, tc.userPath) if tc.expectErr { assert.Error(t, err, tc.desc) assert.Equal(t, "", result) } else { assert.NoError(t, err, tc.desc) assert.NotEmpty(t, result) // Verify result is under baseDir assert.True(t, strings.HasPrefix(result, baseDir), "Result should be under base directory") } }) } } // TestImportHandler_Upload_InvalidSessionPaths verifies session path validation func TestImportHandler_Upload_InvalidSessionPaths(t *testing.T) { testCases := []struct { name string filename string wantCode int wantErr string }{ { name: "parent directory traversal", filename: "../../../etc/passwd", wantCode: 200, // safeJoin sanitizes, creating valid but unexpected path }, { name: "absolute path", filename: "/etc/passwd", wantCode: 200, // safeJoin blocks absolute paths }, { name: "Windows absolute path", filename: "C:\\Windows\\System32\\config\\sam", wantCode: 200, // Windows paths are cleaned }, { name: "encoded null byte", filename: "file%00.txt", wantCode: 200, // URL encoding is not decoded by safeJoin }, } for _, tc := range testCases { t.Run(tc.name, func(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: "test.com", ForwardHost: "localhost", ForwardPort: 8080}, }, } handler.importerservice = &mockImporterAdapter{mockImport} reqBody := map[string]string{ "content": "test.com { reverse_proxy localhost:8080 }", "filename": tc.filename, } 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) assert.Equal(t, tc.wantCode, w.Code, "Unexpected status code") }) }) } } func TestImportHandler_Commit_InvalidSessionUUID_BranchCoverage(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, _ := setupTestHandler(t, tx) reqBody := map[string]any{ "session_uuid": ".", } body, _ := json.Marshal(reqBody) 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.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "invalid session_uuid") }) } 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) gin.SetMode(gin.TestMode) router := gin.New() addAdminMiddleware(router) handler.RegisterRoutes(router.Group("/api/v1")) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/v1/import/cancel", http.NoBody) router.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "session_uuid required") w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid=.", http.NoBody) router.ServeHTTP(w, req) require.Equal(t, http.StatusBadRequest, w.Code) assert.Contains(t, w.Body.String(), "invalid session_uuid") w = httptest.NewRecorder() req = httptest.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid=missing-session", http.NoBody) router.ServeHTTP(w, req) require.Equal(t, http.StatusNotFound, w.Code) assert.Contains(t, w.Body.String(), "session not found") }) } func TestImportHandler_Cancel_TransientUploadCancelled_BranchCoverage(t *testing.T) { testutil.WithTx(t, setupImportTestDB(t), func(tx *gorm.DB) { handler, _, _ := setupTestHandler(t, tx) sessionID := "transient-123" uploadDir := filepath.Join(handler.importDir, "uploads") require.NoError(t, os.MkdirAll(uploadDir, 0o700)) uploadPath := filepath.Join(uploadDir, sessionID+".caddyfile") require.NoError(t, os.WriteFile(uploadPath, []byte("example.com { respond \"ok\" }"), 0o600)) gin.SetMode(gin.TestMode) router := gin.New() addAdminMiddleware(router) handler.RegisterRoutes(router.Group("/api/v1")) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodDelete, "/api/v1/import/cancel?session_uuid="+sessionID, http.NoBody) router.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Code) assert.Contains(t, w.Body.String(), "transient upload cancelled") _, err := os.Stat(uploadPath) require.Error(t, err) assert.True(t, os.IsNotExist(err)) }) }