package rules_test import ( "fmt" "maps" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "reflect" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yusing/godoxy/internal/route" "github.com/yusing/godoxy/internal/route/routes" "github.com/yusing/godoxy/internal/serialization" "golang.org/x/crypto/bcrypt" . "github.com/yusing/godoxy/internal/route/rules" ) // mockUpstream creates a simple upstream handler for testing func mockUpstream(body string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(body)) } } // mockUpstreamWithHeaders creates an upstream that returns specific headers func mockUpstreamWithHeaders(status int, body string, headers http.Header) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { maps.Copy(w.Header(), headers) w.WriteHeader(status) w.Write([]byte(body)) } } func mockRoute(alias string) *route.FileServer { return &route.FileServer{Route: &route.Route{Alias: alias}} } func parseRules(data string, target *Rules) error { _, err := serialization.ConvertString(strings.TrimSpace(data), reflect.ValueOf(target)) return err } func TestHTTPFlow_BasicPreRules(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Custom-Header", r.Header.Get("X-Custom-Header")) w.WriteHeader(http.StatusOK) w.Write([]byte("upstream response")) }) var rules Rules err := parseRules(` - name: add-header on: path / do: set header X-Custom-Header test-value `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "upstream response", w.Body.String()) assert.Equal(t, "test-value", w.Header().Get("X-Custom-Header")) } func TestHTTPFlow_BypassRule(t *testing.T) { upstream := mockUpstream("upstream response") var rules Rules err := parseRules(` - name: bypass-condition on: path /bypass do: bypass - name: should-not-execute on: path /bypass do: error 500 "should not reach here" `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/bypass", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "upstream response", w.Body.String()) } func TestHTTPFlow_TerminatingCommand(t *testing.T) { upstream := mockUpstream("should not be called") var rules Rules err := parseRules(` - name: error-response on: path /error do: error 403 Forbidden - name: should-not-execute on: path /error do: set header X-Header ignored `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/error", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusForbidden, w.Code) assert.Equal(t, "Forbidden\n", w.Body.String()) assert.Empty(t, w.Header().Get("X-Header")) } func TestHTTPFlow_RedirectFlow(t *testing.T) { upstream := mockUpstream("should not be called") var rules Rules err := parseRules(` - name: redirect-rule on: path /old-path do: redirect /new-path `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/old-path", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusTemporaryRedirect, w.Code) // TemporaryRedirect assert.Equal(t, "/new-path", w.Header().Get("Location")) } func TestHTTPFlow_RewriteFlow(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("path: " + r.URL.Path)) }) var rules Rules err := parseRules(` - name: rewrite-rule on: path glob(/api/*) do: rewrite /api/ /v1/ `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/api/users", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "path: /v1/users", w.Body.String()) } func TestHTTPFlow_MultiplePreRules(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("upstream: " + r.Header.Get("X-Request-Id"))) }) var rules Rules err := parseRules(` - name: add-request-id on: path / do: set header X-Request-Id req-123 - name: add-auth-header on: path / do: set header X-Auth-Token token-456 `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "upstream: req-123", w.Body.String()) assert.Equal(t, "token-456", req.Header.Get("X-Auth-Token")) } func TestHTTPFlow_PostResponseRule(t *testing.T) { upstream := mockUpstreamWithHeaders(http.StatusOK, "success", http.Header{ "X-Upstream": []string{"upstream-value"}, }) tempFile := TestRandomFileName() var rules Rules err := parseRules(fmt.Sprintf(` - name: log-response on: path /test do: log info %s "$req_method $status_code" `, tempFile), &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/test", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "success", w.Body.String()) assert.Equal(t, "upstream-value", w.Header().Get("X-Upstream")) // Check log file content := TestFileContent(tempFile) require.NoError(t, err) assert.Equal(t, "GET 200\n", string(content)) } func TestHTTPFlow_ResponseRuleWithStatusCondition(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/success" { w.WriteHeader(http.StatusOK) w.Write([]byte("success")) } else { w.WriteHeader(http.StatusNotFound) w.Write([]byte("not found")) } }) var rules Rules // Create a temporary file for logging tempFile := TestRandomFileName() err := parseRules(fmt.Sprintf(` - name: log-errors on: status 4xx do: log error %s "$req_url returned $status_code" `, tempFile), &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) // Test successful request (should not log) req1 := httptest.NewRequest(http.MethodGet, "/success", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) assert.Equal(t, http.StatusOK, w1.Code) // Test error request (should log) req2 := httptest.NewRequest(http.MethodGet, "/notfound", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) assert.Equal(t, http.StatusNotFound, w2.Code) // Check log file content := TestFileContent(tempFile) require.NoError(t, err) lines := strings.Split(strings.TrimSpace(string(content)), "\n") require.Len(t, lines, 1, "only 4xx requests should be logged") assert.Equal(t, "/notfound returned 404", lines[0]) } func TestHTTPFlow_ConditionalRules(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("hello " + r.Header.Get("X-Username"))) }) var rules Rules err := parseRules(` - name: auth-required on: header Authorization do: | set header X-Username authenticated-user set resp_header X-Username authenticated-user - name: default do: | set header X-Username anonymous set resp_header X-Username anonymous `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) // Test with Authorization header req1 := httptest.NewRequest(http.MethodGet, "/", nil) req1.Header.Set("Authorization", "Bearer token") w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "hello authenticated-user", w1.Body.String()) assert.Equal(t, "authenticated-user", w1.Header().Get("X-Username")) // Test without Authorization header req2 := httptest.NewRequest(http.MethodGet, "/", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "hello anonymous", w2.Body.String()) assert.Equal(t, "anonymous", w2.Header().Get("X-Username")) } func TestHTTPFlow_ComplexFlowWithPreAndPostRules(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Simulate different responses based on path if r.URL.Path == "/protected" { if r.Header.Get("X-Auth") != "valid" { w.WriteHeader(http.StatusUnauthorized) w.Write([]byte("unauthorized")) return } } w.Header().Set("X-Response-Time", "100ms") w.WriteHeader(http.StatusOK) w.Write([]byte("success")) }) // Create temporary files for logging logFile := TestRandomFileName() errorLogFile := TestRandomFileName() var rules Rules err := parseRules(fmt.Sprintf(` - name: add-correlation-id do: set resp_header X-Correlation-Id random_uuid - name: validate-auth on: path /protected do: require_basic_auth "Protected Area" - name: log-all-requests do: | log info %q "$req_method $req_url -> $status_code" - name: log-errors on: status 4xx do: | log error %q "ERROR: $req_method $req_url $status_code" `, logFile, errorLogFile), &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) // Test successful request req1 := httptest.NewRequest(http.MethodGet, "/public", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "success", w1.Body.String()) assert.Equal(t, "random_uuid", w1.Header().Get("X-Correlation-Id")) assert.Equal(t, "100ms", w1.Header().Get("X-Response-Time")) // Test unauthorized protected request req2 := httptest.NewRequest(http.MethodGet, "/protected", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) assert.Equal(t, http.StatusUnauthorized, w2.Code) assert.Equal(t, "Unauthorized\n", w2.Body.String()) // Test authorized protected request req3 := httptest.NewRequest(http.MethodGet, "/protected", nil) req3.SetBasicAuth("user", "pass") w3 := httptest.NewRecorder() handler.ServeHTTP(w3, req3) // This should fail because our simple upstream expects X-Auth: valid header // but the basic auth requirement should add the appropriate header assert.Equal(t, http.StatusUnauthorized, w3.Code) // Check log files logContent := TestFileContent(logFile) lines := strings.Split(strings.TrimSpace(string(logContent)), "\n") require.Len(t, lines, 3, "all requests should be logged") assert.Equal(t, "GET /public -> 200", lines[0]) assert.Equal(t, "GET /protected -> 401", lines[1]) assert.Equal(t, "GET /protected -> 401", lines[2]) errorLogContent := TestFileContent(errorLogFile) // Should have at least one 401 error logged lines = strings.Split(strings.TrimSpace(string(errorLogContent)), "\n") require.Len(t, lines, 2, "all errors should be logged") assert.Equal(t, "ERROR: GET /protected 401", lines[0]) assert.Equal(t, "ERROR: GET /protected 401", lines[1]) } func TestHTTPFlow_DefaultRule(t *testing.T) { upstream := mockUpstream("upstream response") var rules Rules err := parseRules(` - name: default do: set resp_header X-Default-Applied true - name: special-rule on: path /special do: set resp_header X-Special-Handled true `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) // Test default rule req1 := httptest.NewRequest(http.MethodGet, "/regular", nil) w1 := httptest.NewRecorder() handler.ServeHTTP(w1, req1) assert.Equal(t, http.StatusOK, w1.Code) assert.Equal(t, "true", w1.Header().Get("X-Default-Applied")) assert.Empty(t, w1.Header().Get("X-Special-Handled")) // Test special rule + default rule req2 := httptest.NewRequest(http.MethodGet, "/special", nil) w2 := httptest.NewRecorder() handler.ServeHTTP(w2, req2) assert.Equal(t, http.StatusOK, w2.Code) assert.Equal(t, "true", w2.Header().Get("X-Default-Applied")) assert.Equal(t, "true", w2.Header().Get("X-Special-Handled")) } func TestHTTPFlow_HeaderManipulation(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Echo back a header headerValue := r.Header.Get("X-Test-Header") w.Header().Set("X-Echoed-Header", headerValue) w.WriteHeader(http.StatusOK) w.Write([]byte("header echoed")) }) var rules Rules err := parseRules(` - name: remove-sensitive-header do: remove resp_header X-Secret - name: add-custom-header do: add resp_header X-Custom-Header custom-value - name: modify-existing-header on: header X-Test-Header do: set header X-Test-Header modified-value `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("X-Secret", "secret-value") req.Header.Set("X-Test-Header", "original-value") w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, "modified-value", w.Header().Get("X-Echoed-Header")) assert.Equal(t, "custom-value", w.Header().Get("X-Custom-Header")) // Ensure the secret header was removed and not passed to upstream // (we can't directly test this, but the upstream shouldn't see it) } func TestHTTPFlow_QueryParameterHandling(t *testing.T) { upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() w.WriteHeader(http.StatusOK) w.Write([]byte("query: " + query.Get("param"))) }) var rules Rules err := parseRules(` - name: add-query-param on: query param do: set query param added-value `, &rules) require.NoError(t, err) handler := rules.BuildHandler(upstream) req := httptest.NewRequest(http.MethodGet, "/path?param=original", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assert.Equal(t, http.StatusOK, w.Code) // The set command should have modified the query parameter assert.Equal(t, "query: added-value", w.Body.String()) } func TestHTTPFlow_ServeCommand(t *testing.T) { // Create a temporary directory with test files tempDir := t.TempDir() // Create test files directly in the temp directory testFile := filepath.Join(tempDir, "index.html") err := os.WriteFile(testFile, []byte("