package caddy import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestNewImporter(t *testing.T) { importer := NewImporter("/usr/bin/caddy") assert.NotNil(t, importer) assert.Equal(t, "/usr/bin/caddy", importer.caddyBinaryPath) importerDefault := NewImporter("") assert.NotNil(t, importerDefault) assert.Equal(t, "caddy", importerDefault.caddyBinaryPath) } func TestImporter_ParseCaddyfile_NotFound(t *testing.T) { importer := NewImporter("caddy") _, err := importer.ParseCaddyfile("non-existent-file") assert.Error(t, err) assert.Contains(t, err.Error(), "caddyfile not found") } type MockExecutor struct { Output []byte Err error ExecuteFunc func(name string, args ...string) ([]byte, error) // Custom execution logic } func (m *MockExecutor) Execute(name string, args ...string) ([]byte, error) { if m.ExecuteFunc != nil { return m.ExecuteFunc(name, args...) } return m.Output, m.Err } func TestImporter_ParseCaddyfile_Success(t *testing.T) { importer := NewImporter("caddy") mockExecutor := &MockExecutor{ Output: []byte(`{"apps": {"http": {"servers": {}}}}`), Err: nil, } importer.executor = mockExecutor // Create a dummy file to bypass os.Stat check tmpFile := filepath.Join(t.TempDir(), "Caddyfile") err := os.WriteFile(tmpFile, []byte("foo"), 0o600) assert.NoError(t, err) output, err := importer.ParseCaddyfile(tmpFile) assert.NoError(t, err) assert.JSONEq(t, `{"apps": {"http": {"servers": {}}}}`, string(output)) } func TestImporter_ParseCaddyfile_Failure(t *testing.T) { importer := NewImporter("caddy") mockExecutor := &MockExecutor{ Output: []byte("syntax error"), Err: assert.AnError, } importer.executor = mockExecutor // Create a dummy file tmpFile := filepath.Join(t.TempDir(), "Caddyfile") err := os.WriteFile(tmpFile, []byte("foo"), 0o600) assert.NoError(t, err) _, err = importer.ParseCaddyfile(tmpFile) assert.Error(t, err) assert.Contains(t, err.Error(), "caddy adapt failed") } func TestImporter_ExtractHosts(t *testing.T) { importer := NewImporter("caddy") // Test Case 1: Empty Config emptyJSON := []byte(`{}`) result, err := importer.ExtractHosts(emptyJSON) assert.NoError(t, err) assert.Empty(t, result.Hosts) // Test Case 2: Invalid JSON invalidJSON := []byte(`{invalid`) _, err = importer.ExtractHosts(invalidJSON) assert.Error(t, err) // Test Case 3: Valid Config with Reverse Proxy validJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8080"}] } ] } ] } } } } }`) result, err = importer.ExtractHosts(validJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "example.com", result.Hosts[0].DomainNames) assert.Equal(t, "127.0.0.1", result.Hosts[0].ForwardHost) assert.Equal(t, 8080, result.Hosts[0].ForwardPort) // Test Case 4: Duplicate Domain duplicateJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["example.com"]}], "handle": [{"handler": "reverse_proxy"}] }, { "match": [{"host": ["example.com"]}], "handle": [{"handler": "reverse_proxy"}] } ] } } } } }`) result, err = importer.ExtractHosts(duplicateJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Len(t, result.Conflicts, 1) assert.Equal(t, "example.com", result.Conflicts[0]) // Test Case 5: Unsupported Features unsupportedJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["files.example.com"]}], "handle": [ {"handler": "file_server"}, {"handler": "rewrite"} ] } ] } } } } }`) result, err = importer.ExtractHosts(unsupportedJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Len(t, result.Hosts[0].Warnings, 2) assert.Contains(t, result.Hosts[0].Warnings, "File server directives not supported") assert.Contains(t, result.Hosts[0].Warnings, "Rewrite rules not supported - manual configuration required") // Test Case 6: SSL Detection via Listen Address (:443) sslViaListenJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "listen": [":443"], "routes": [ { "match": [{"host": ["secure.example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:9000"}] } ] } ] } } } } }`) result, err = importer.ExtractHosts(sslViaListenJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "secure.example.com", result.Hosts[0].DomainNames) assert.True(t, result.Hosts[0].SSLForced, "SSLForced should be true when server listens on :443") } func TestImporter_ImportFile(t *testing.T) { importer := NewImporter("caddy") mockExecutor := &MockExecutor{ Output: []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8080"}] } ] } ] } } } } }`), Err: nil, } importer.executor = mockExecutor // Create a dummy file tmpFile := filepath.Join(t.TempDir(), "Caddyfile") // #nosec G306 -- Test fixture Caddyfile err := os.WriteFile(tmpFile, []byte("foo"), 0o644) assert.NoError(t, err) result, err := importer.ImportFile(tmpFile) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "example.com", result.Hosts[0].DomainNames) } func TestConvertToProxyHosts(t *testing.T) { parsedHosts := []ParsedHost{ { DomainNames: "example.com", ForwardScheme: "http", ForwardHost: "127.0.0.1", ForwardPort: 8080, SSLForced: true, WebsocketSupport: true, }, { DomainNames: "invalid.com", ForwardHost: "", // Invalid }, } hosts := ConvertToProxyHosts(parsedHosts) assert.Len(t, hosts, 1) assert.Equal(t, "example.com", hosts[0].DomainNames) assert.Equal(t, "127.0.0.1", hosts[0].ForwardHost) assert.Equal(t, 8080, hosts[0].ForwardPort) assert.True(t, hosts[0].SSLForced) assert.True(t, hosts[0].WebsocketSupport) } func TestImporter_ValidateCaddyBinary(t *testing.T) { importer := NewImporter("caddy") // Success importer.executor = &MockExecutor{Output: []byte("v2.0.0"), Err: nil} err := importer.ValidateCaddyBinary() assert.NoError(t, err) // Failure importer.executor = &MockExecutor{Output: nil, Err: assert.AnError} err = importer.ValidateCaddyBinary() assert.Error(t, err) assert.Equal(t, "caddy binary not found or not executable", err.Error()) } func TestBackupCaddyfile(t *testing.T) { tmpDir := t.TempDir() originalFile := filepath.Join(tmpDir, "Caddyfile") // #nosec G306 -- Test fixture file with standard read permissions err := os.WriteFile(originalFile, []byte("original content"), 0o644) assert.NoError(t, err) backupDir := filepath.Join(tmpDir, "backups") // Success backupPath, err := BackupCaddyfile(originalFile, backupDir) assert.NoError(t, err) assert.FileExists(t, backupPath) content, err := os.ReadFile(backupPath) // #nosec G304 -- Test reading backup file created in test assert.NoError(t, err) assert.Equal(t, "original content", string(content)) // Failure - Source not found _, err = BackupCaddyfile("non-existent", backupDir) assert.Error(t, err) } func TestDefaultExecutor_Execute(t *testing.T) { executor := &DefaultExecutor{} output, err := executor.Execute("echo", "hello") assert.NoError(t, err) assert.Equal(t, "hello\n", string(output)) } // TestDefaultExecutor_Execute_StderrCapture verifies that stderr is captured func TestDefaultExecutor_Execute_StderrCapture(t *testing.T) { executor := &DefaultExecutor{} // Use a command that writes to stderr and exits with error // "sh -c" allows us to write to stderr explicitly output, err := executor.Execute("sh", "-c", "echo 'error message' >&2; exit 1") // Error should be returned assert.Error(t, err) // Output should contain the stderr message (due to CombinedOutput) assert.Contains(t, string(output), "error message") } // TestImporter_NormalizeCaddyfile tests the Caddyfile normalization function func TestImporter_NormalizeCaddyfile(t *testing.T) { tests := []struct { name string input string expectError bool allowEmpty bool }{ { name: "single-line format processes without error", input: "test.example.com { reverse_proxy localhost:3000 }", }, { name: "already formatted content processes without error", input: "test.example.com {\n\treverse_proxy localhost:3000\n}\n", }, { name: "empty input handles gracefully", input: "", allowEmpty: true, }, { name: "nested blocks process without error", input: "test.com { handle /api* { reverse_proxy api:8080 } }", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { importer := NewImporter("caddy") // Create a mock executor that simulates caddy fmt success by returning empty output // The actual file modification would be done by the real caddy binary mockExecutor := &MockExecutor{ Output: []byte{}, // Empty output means success Err: nil, } importer.executor = mockExecutor // Note: With mock executor, the file won't actually be formatted, // but we can verify the function handles the flow without errors output, err := importer.NormalizeCaddyfile(tt.input) if tt.expectError { assert.Error(t, err) return } // With mock, output will be the input (since file isn't actually modified) // The important test is that there's no error assert.NoError(t, err) // Output should contain the original content since mock doesn't modify file // unless empty input is expected if !tt.allowEmpty { assert.NotEmpty(t, output) } }) } } // TestImporter_NormalizeCaddyfile_Error tests error handling func TestImporter_NormalizeCaddyfile_Error(t *testing.T) { importer := NewImporter("caddy") // Mock executor that returns error mockExecutor := &MockExecutor{ Output: []byte("Error: invalid caddyfile syntax"), Err: assert.AnError, } importer.executor = mockExecutor _, err := importer.NormalizeCaddyfile("invalid { syntax") assert.Error(t, err) assert.Contains(t, err.Error(), "caddy fmt failed") } // TestImporter_NormalizeCaddyfile_Integration tests with real caddy binary (if available) func TestImporter_NormalizeCaddyfile_Integration(t *testing.T) { // Skip if caddy binary not available importer := NewImporter("caddy") if err := importer.ValidateCaddyBinary(); err != nil { t.Skip("Caddy binary not available, skipping integration test") } tests := []struct { name string input string validateFn func(t *testing.T, output string) }{ { name: "real single-line format", input: "test.local { reverse_proxy localhost:8080 }", validateFn: func(t *testing.T, output string) { // Verify it has newlines and proper formatting assert.Contains(t, output, "test.local") assert.Contains(t, output, "{") assert.Contains(t, output, "reverse_proxy") assert.Contains(t, output, "localhost:8080") assert.Contains(t, output, "}") // Should be multi-line lines := len(output) - len(string([]rune(output)[0])) assert.Greater(t, lines, 1, "Should have multiple lines") }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { output, err := importer.NormalizeCaddyfile(tt.input) assert.NoError(t, err) if tt.validateFn != nil { tt.validateFn(t, output) } }) } } // TestDefaultExecutor_Execute_Timeout verifies the 5-second timeout triggers correctly func TestDefaultExecutor_Execute_Timeout(t *testing.T) { executor := &DefaultExecutor{} // Use "sleep 10" to trigger the 5-second timeout output, err := executor.Execute("sleep", "10") // Error must be returned assert.Error(t, err) // Error message must contain the timeout message assert.Contains(t, err.Error(), "command timed out after 5 seconds") assert.Contains(t, err.Error(), "sleep") // Output may be empty or partial _ = output } // TestImporter_NormalizeCaddyfile_ReadError tests the error path when reading the formatted file fails func TestImporter_NormalizeCaddyfile_ReadError(t *testing.T) { importer := NewImporter("caddy") // Mock executor that succeeds but deletes the temp file before returning // This simulates the file being removed after caddy fmt writes it mockExecutor := &MockExecutor{ ExecuteFunc: func(name string, args ...string) ([]byte, error) { // The temp file path is the last argument (caddy fmt --overwrite ) if len(args) >= 3 && args[0] == "fmt" && args[1] == "--overwrite" { // Delete the temp file to trigger ReadFile error _ = os.Remove(args[2]) } return []byte{}, nil }, } importer.executor = mockExecutor _, err := importer.NormalizeCaddyfile("test.local { reverse_proxy localhost:8080 }") assert.Error(t, err) assert.Contains(t, err.Error(), "failed to read formatted file") } // TestParseCaddyfile_PathTraversal tests path traversal rejection func TestParseCaddyfile_PathTraversal(t *testing.T) { importer := NewImporter("caddy") tests := []struct { name string path string expectError string }{ { name: "double dot prefix", path: "../etc/passwd", expectError: "invalid caddyfile path", }, { name: "just double dot", path: "..", expectError: "invalid caddyfile path", }, { name: "empty path", path: "", expectError: "invalid caddyfile path", }, { name: "dot only", path: ".", expectError: "invalid caddyfile path", }, { name: "relative double dot", path: "foo/../../../etc/passwd", expectError: "invalid caddyfile path", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := importer.ParseCaddyfile(tt.path) assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectError) }) } } // TestExtractHosts_WebsocketHandler tests websocket header extraction func TestExtractHosts_WebsocketHandler(t *testing.T) { importer := NewImporter("caddy") // JSON config with websocket header websocketJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["ws.example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8080"}], "headers": { "Upgrade": ["websocket"] } } ] } ] } } } } }`) result, err := importer.ExtractHosts(websocketJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "ws.example.com", result.Hosts[0].DomainNames) assert.True(t, result.Hosts[0].WebsocketSupport, "WebsocketSupport should be true when Upgrade header contains websocket") } // TestExtractHosts_SubrouteHandler tests extraction of handlers from subroutes func TestExtractHosts_SubrouteHandler(t *testing.T) { importer := NewImporter("caddy") // JSON config with subroute containing reverse_proxy subrouteJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["nested.example.com"]}], "handle": [ { "handler": "subroute", "routes": [ { "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "backend:9000"}] } ] } ] } ] } ] } } } } }`) result, err := importer.ExtractHosts(subrouteJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "nested.example.com", result.Hosts[0].DomainNames) assert.Equal(t, "backend", result.Hosts[0].ForwardHost) assert.Equal(t, 9000, result.Hosts[0].ForwardPort) } // TestBackupCaddyfile_PathTraversal tests backup path validation func TestBackupCaddyfile_PathTraversal(t *testing.T) { tmpDir := t.TempDir() backupDir := filepath.Join(tmpDir, "backups") tests := []struct { name string originalPath string expectError string }{ { name: "double dot prefix", originalPath: "../etc/passwd", expectError: "invalid original path", }, { name: "empty path", originalPath: "", expectError: "invalid original path", }, { name: "dot only", originalPath: ".", expectError: "invalid original path", }, { name: "relative traversal", originalPath: "foo/../../../etc/passwd", expectError: "invalid original path", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := BackupCaddyfile(tt.originalPath, backupDir) assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectError) }) } } // TestBackupCaddyfile_SourceNotReadable tests error when source can't be read func TestBackupCaddyfile_SourceNotReadable(t *testing.T) { tmpDir := t.TempDir() backupDir := filepath.Join(tmpDir, "backups") // Non-existent file _, err := BackupCaddyfile(filepath.Join(tmpDir, "nonexistent.txt"), backupDir) assert.Error(t, err) assert.Contains(t, err.Error(), "reading original file") } // TestExtractHosts_SplitHostPortFallback tests the fallback parsing when net.SplitHostPort fails func TestExtractHosts_SplitHostPortFallback(t *testing.T) { // Enable the fallback branch oldVal := forceSplitFallback forceSplitFallback = true defer func() { forceSplitFallback = oldVal }() importer := NewImporter("caddy") // Test with valid host:port format (fallback should still parse it) validJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["fallback.example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "backend:3000"}] } ] } ] } } } } }`) result, err := importer.ExtractHosts(validJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "backend", result.Hosts[0].ForwardHost) assert.Equal(t, 3000, result.Hosts[0].ForwardPort) } // TestExtractHosts_HostOnly tests fallback when dial is just a hostname without port func TestExtractHosts_HostOnly(t *testing.T) { // Enable the fallback branch oldVal := forceSplitFallback forceSplitFallback = true defer func() { forceSplitFallback = oldVal }() importer := NewImporter("caddy") // Test with host only (no port) hostOnlyJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["hostonly.example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "backend-service"}] } ] } ] } } } } }`) result, err := importer.ExtractHosts(hostOnlyJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "backend-service", result.Hosts[0].ForwardHost) assert.Equal(t, 80, result.Hosts[0].ForwardPort, "Should default to port 80") } // TestExtractHosts_InvalidPortFallback tests fallback when port is invalid func TestExtractHosts_InvalidPortFallback(t *testing.T) { // Enable the fallback branch oldVal := forceSplitFallback forceSplitFallback = true defer func() { forceSplitFallback = oldVal }() importer := NewImporter("caddy") // Test with invalid port invalidPortJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "routes": [ { "match": [{"host": ["invalidport.example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "backend:invalidport"}] } ] } ] } } } } }`) result, err := importer.ExtractHosts(invalidPortJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "backend", result.Hosts[0].ForwardHost) assert.Equal(t, 80, result.Hosts[0].ForwardPort, "Should default to port 80 on invalid port") } // TestExtractHosts_TLSConnectionPolicies tests SSL detection via TLS connection policies func TestExtractHosts_TLSConnectionPolicies(t *testing.T) { importer := NewImporter("caddy") // JSON config with TLS connection policies set tlsJSON := []byte(`{ "apps": { "http": { "servers": { "srv0": { "tls_connection_policies": [{"alpn": ["h2", "http/1.1"]}], "routes": [ { "match": [{"host": ["secure.example.com"]}], "handle": [ { "handler": "reverse_proxy", "upstreams": [{"dial": "127.0.0.1:8443"}] } ] } ] } } } } }`) result, err := importer.ExtractHosts(tlsJSON) assert.NoError(t, err) assert.Len(t, result.Hosts, 1) assert.Equal(t, "secure.example.com", result.Hosts[0].DomainNames) assert.True(t, result.Hosts[0].SSLForced, "SSLForced should be true when tls_connection_policies is set") assert.Equal(t, "https", result.Hosts[0].ForwardScheme, "ForwardScheme should be https for SSL") }