Files
Charon/backend/internal/api/handlers/import_handler_test.go
GitHub Actions e6c4e46dd8 chore: Refactor test setup for Gin framework
- Removed redundant `gin.SetMode(gin.TestMode)` calls from individual test files.
- Introduced a centralized `TestMain` function in `testmain_test.go` to set the Gin mode for all tests.
- Ensured consistent test environment setup across various handler test files.
2026-03-25 22:00:07 +00:00

1021 lines
31 KiB
Go

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
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
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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()
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)
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))
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))
})
}