- Implemented comprehensive tests for security toggle handlers in `security_toggles_test.go`, covering enable/disable functionality for ACL, WAF, Cerberus, CrowdSec, and RateLimit. - Added sample JSON response for CrowdSec decisions in `lapi_decisions_response.json`. - Created aggressive preset configuration for CrowdSec in `preset_aggressive.json`. - Documented backend coverage, security fixes, and E2E testing improvements in `2026-02-02_backend_coverage_security_fix.md`. - Developed a detailed backend test coverage restoration plan in `current_spec.md` to address existing gaps and improve overall test coverage to 86%+.
755 lines
22 KiB
Go
755 lines
22 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/caddy"
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
"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
|
|
}
|
|
|
|
// 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()
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
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()
|
|
handler.RegisterRoutes(router.Group("/api/v1"))
|
|
router.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, tc.wantCode, w.Code, "Unexpected status code")
|
|
})
|
|
})
|
|
}
|
|
}
|