Files
godoxy-yusing/internal/route/rules/http_flow_block_test.go
yusing 54be056530 refactor(rules): improve termination detection and block parsing logic
Refactors the termination detection in the rules DSL to properly handle if-block and if-else-block commands.

Adds new functions `commandsTerminateInPre`, `commandTerminatesInPre`, and `ifElseBlockTerminatesInPre`
to recursively check if command sequences terminate in the pre-phase.

Also improves the Parse function to try block syntax first with proper error handling and fallback to YAML.

Includes test cases for dead code detection with terminating handlers in conditional blocks.
2026-02-24 01:05:54 +08:00

1358 lines
37 KiB
Go

package rules_test
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yusing/godoxy/internal/route/routes"
"golang.org/x/crypto/bcrypt"
. "github.com/yusing/godoxy/internal/route/rules"
)
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_TerminatingCommand(t *testing.T) {
upstream := mockUpstream(http.StatusOK, "should not be called")
var rules Rules
err := parseRules(`
path /error {
error 403 Forbidden
}
path /error {
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(http.StatusOK, "should not be called")
var rules Rules
err := parseRules(`
path /old-path {
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)
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(`
path glob(/api/*) {
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(`
path / {
set header X-Request-Id req-123
}
path / {
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(`
path /test {
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)
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(`
status 4xx {
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)
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(`
header Authorization {
set header X-Username authenticated-user
set resp_header X-Username authenticated-user
}
default {
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)
fmt.Fprint(w, "unauthorized")
return
}
}
w.Header().Set("X-Response-Time", "100ms")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "success")
})
// Create temporary files for logging
logFile := TestRandomFileName()
errorLogFile := TestRandomFileName()
var rules Rules
err := parseRules(fmt.Sprintf(`
{
set resp_header X-Correlation-Id random_uuid
}
path /protected {
require_basic_auth "Protected Area"
}
{
log info %q "$req_method $req_url -> $status_code"
}
status 4xx {
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, w2.Body.String(), "Unauthorized\n")
// 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(http.StatusOK, "upstream response")
var rules Rules
err := parseRules(`
default {
set resp_header X-Default-Applied true
}
path /special {
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 should not run)
req2 := httptest.NewRequest(http.MethodGet, "/special", nil)
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
assert.Empty(t, w2.Header().Get("X-Default-Applied"))
assert.Equal(t, "true", w2.Header().Get("X-Special-Handled"))
}
func TestHTTPFlow_UnconditionalRuleSuppressesDefaultRule(t *testing.T) {
upstream := mockUpstream(http.StatusOK, "upstream response")
var rules Rules
err := parseRules(`
{
set resp_header X-Unconditional true
}
default {
set resp_header X-Default-Applied true
}
path /never-match {
set resp_header X-Never-Match true
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodGet, "/special", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "true", w.Header().Get("X-Unconditional"))
assert.Empty(t, w.Header().Get("X-Default-Applied"))
assert.Empty(t, w.Header().Get("X-Never-Match"))
}
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(`
{
remove resp_header X-Secret
add resp_header X-Custom-Header custom-value
}
header X-Test-Header {
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_NestedBlocks_RemoteOverride(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Remote-Type", r.Header.Get("X-Remote-Type"))
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
var rules Rules
err := parseRules(`
header X-Test-Header {
set header X-Remote-Type public
@remote 127.0.0.1 | remote 192.168.0.0/16 {
set header X-Remote-Type private
}
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// Localhost => private
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1.Header.Set("X-Test-Header", "1")
req1.RemoteAddr = "127.0.0.1:12345"
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Equal(t, "private", w1.Header().Get("X-Remote-Type"))
// Public IP => public
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.Header.Set("X-Test-Header", "1")
req2.RemoteAddr = "1.1.1.1:12345"
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
assert.Equal(t, "public", w2.Header().Get("X-Remote-Type"))
}
func TestHTTPFlow_NestedBlocks_ElifElse(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Mode", r.Header.Get("X-Mode"))
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
var rules Rules
err := parseRules(`
header X-Test-Header {
@method GET {
set header X-Mode get
} elif method POST {
set header X-Mode post
} else {
set header X-Mode other
}
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// GET => get
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1.Header.Set("X-Test-Header", "1")
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Equal(t, "get", w1.Header().Get("X-Mode"))
// POST => post
req2 := httptest.NewRequest(http.MethodPost, "/", nil)
req2.Header.Set("X-Test-Header", "1")
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
assert.Equal(t, "post", w2.Header().Get("X-Mode"))
// other methods => else branch
req3 := httptest.NewRequest(http.MethodPut, "/", nil)
req3.Header.Set("X-Test-Header", "1")
w3 := httptest.NewRecorder()
handler.ServeHTTP(w3, req3)
assert.Equal(t, http.StatusOK, w3.Code)
assert.Equal(t, "other", w3.Header().Get("X-Mode"))
// no match
req4 := httptest.NewRequest(http.MethodDelete, "/", nil)
w4 := httptest.NewRecorder()
handler.ServeHTTP(w4, req4)
assert.Equal(t, http.StatusOK, w4.Code)
assert.Equal(t, "", w4.Header().Get("X-Mode"))
}
func TestHTTPFlow_NestedBlocks_TerminatingActionStopsFlow(t *testing.T) {
called := false
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
w.Write([]byte("upstream"))
})
var rules Rules
err := parseRules(`
path / {
set header X-Pre pre
@header X-Block {
error 403 "blocked"
}
set resp_header X-After should-not-run
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// Without X-Block => should reach upstream and execute non-terminating commands
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.True(t, called)
assert.Equal(t, "should-not-run", w1.Header().Get("X-After"))
assert.Equal(t, "pre", req1.Header.Get("X-Pre"))
// With X-Block => nested terminating action should stop processing before upstream
called = false
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.Header.Set("X-Block", "1")
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, 403, w2.Code)
assert.Equal(t, "blocked\n", w2.Body.String())
assert.False(t, called, "nested error should terminate before calling upstream")
assert.Empty(t, w2.Header().Get("X-After"), "commands after the nested block should not run")
}
func TestHTTPFlow_NestedBlocks_InResponseRule_ModifiesResponseByRequestMethod(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("upstream"))
})
var rules Rules
err := parseRules(`
{
set header X-Method "should-be-overridden"
@method POST {
set header X-Method "post"
} elif method GET {
set header X-Method "get"
} else {
set header X-Method "other"
}
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
t.Run(http.MethodGet, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "get", req.Header.Get("X-Method"))
})
t.Run(http.MethodPost, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "post", req.Header.Get("X-Method"))
})
t.Run("other", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "other", req.Header.Get("X-Method"))
})
}
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(`
query param {
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, err := os.MkdirTemp("", "test-serve-*")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
// Create test files directly in the temp directory
testFile := filepath.Join(tempDir, "index.html")
err = os.WriteFile(testFile, []byte("<h1>Test Page</h1>"), 0o644)
require.NoError(t, err)
var rules Rules
err = parseRules(fmt.Sprintf(`
path glob(/files/*) {
serve %s
}
`, tempDir), &rules)
require.NoError(t, err)
handler := rules.BuildHandler(mockUpstream(http.StatusOK, "should not be called"))
// Test serving a file - serve command serves files relative to the root directory
// The path /files/index.html gets mapped to tempDir + "/files/index.html"
// We need to create the file at the expected path
filesDir := filepath.Join(tempDir, "files")
err = os.Mkdir(filesDir, 0o755)
require.NoError(t, err)
filesIndexFile := filepath.Join(filesDir, "index.html")
err = os.WriteFile(filesIndexFile, []byte("<h1>Test Page</h1>"), 0o644)
require.NoError(t, err)
req1 := httptest.NewRequest(http.MethodGet, "/files/index.html", nil)
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
// The serve command should work, but might redirect
// Let's just verify it doesn't call the upstream
assert.NotEqual(t, "should not be called", w1.Body.String())
// Test file not found
req2 := httptest.NewRequest(http.MethodGet, "/files/nonexistent.html", nil)
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusNotFound, w2.Code)
}
func TestHTTPFlow_ProxyCommand(t *testing.T) {
// Create a mock upstream server
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Upstream-Header", "upstream-value")
w.WriteHeader(http.StatusOK)
w.Write([]byte("upstream response"))
}))
defer upstreamServer.Close()
var rules Rules
err := parseRules(fmt.Sprintf(`
path glob(/api/*) {
proxy %s
}
`, upstreamServer.URL), &rules)
require.NoError(t, err)
handler := rules.BuildHandler(mockUpstream(http.StatusOK, "should not be called"))
req := httptest.NewRequest(http.MethodGet, "/api/test", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// The proxy command should forward the request to the upstream server
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "upstream response", w.Body.String())
assert.Equal(t, "upstream-value", w.Header().Get("X-Upstream-Header"))
}
func TestHTTPFlow_NotifyCommand(t *testing.T) {
upstream := mockUpstream(http.StatusOK, "ok")
var rules Rules
err := parseRules(`
path /notify {
notify info test-provider "title $req_method" "body $req_url $status_code"
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodGet, "/notify", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "ok", w.Body.String())
}
func TestHTTPFlow_FormConditions(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("form processed"))
})
var rules Rules
err := parseRules(`
form username {
set resp_header X-Username "$form(username)"
}
postform email {
set resp_header X-Email "$postform(email)"
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// Test form condition
formData := url.Values{"username": {"john_doe"}}
req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(formData.Encode()))
req1.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Equal(t, "john_doe", w1.Header().Get("X-Username"))
// Test postform condition
postFormData := url.Values{"email": {"john@example.com"}}
req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(postFormData.Encode()))
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
assert.Equal(t, "john@example.com", w2.Header().Get("X-Email"))
}
func TestHTTPFlow_RemoteConditions(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("remote processed"))
})
var rules Rules
err := parseRules(`
remote 127.0.0.1 {
set resp_header X-Access "local"
}
remote 192.168.0.0/16 {
error 403 "Private network blocked"
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// Test localhost condition
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1.RemoteAddr = "127.0.0.1:12345"
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Equal(t, "local", w1.Header().Get("X-Access"))
// Test private network block
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.RemoteAddr = "192.168.1.100:12345"
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, 403, w2.Code)
assert.Equal(t, "Private network blocked\n", w2.Body.String())
}
func TestHTTPFlow_BasicAuthConditions(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("auth processed"))
})
// Generate bcrypt hashes for passwords
adminHash, err := bcrypt.GenerateFromPassword([]byte("adminpass"), bcrypt.DefaultCost)
require.NoError(t, err)
guestHash, err := bcrypt.GenerateFromPassword([]byte("guestpass"), bcrypt.DefaultCost)
require.NoError(t, err)
var rules Rules
err = parseRules(fmt.Sprintf(`
basic_auth admin %q {
set resp_header X-Auth-Status "admin"
}
basic_auth guest %q {
set resp_header X-Auth-Status "guest"
}
`, string(adminHash), string(guestHash)), &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// Test admin user
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1.SetBasicAuth("admin", "adminpass")
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Equal(t, "admin", w1.Header().Get("X-Auth-Status"))
// Test guest user
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2.SetBasicAuth("guest", "guestpass")
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
assert.Equal(t, "guest", w2.Header().Get("X-Auth-Status"))
}
func TestHTTPFlow_RouteConditions(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("route processed"))
})
var rules Rules
err := parseRules(`
route backend {
set resp_header X-Route "backend"
}
route frontend {
set resp_header X-Route "frontend"
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// Test API route
req1 := httptest.NewRequest(http.MethodGet, "/", nil)
req1 = routes.WithRouteContext(req1, mockRoute("backend"))
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Equal(t, "backend", w1.Header().Get("X-Route"))
// Test admin route
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
req2 = routes.WithRouteContext(req2, mockRoute("frontend"))
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
assert.Equal(t, "frontend", w2.Header().Get("X-Route"))
}
func TestHTTPFlow_ResponseStatusConditions(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprint(w, "method not allowed")
})
var rules Rules
err := parseRules(`
status 405 {
error 405 'error'
}
`, &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.StatusMethodNotAllowed, w.Code)
assert.Equal(t, "error\n", w.Body.String())
}
func TestHTTPFlow_ResponseHeaderConditions(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Response-Header", "response header")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "processed")
})
t.Run("any_value", func(t *testing.T) {
var rules Rules
err := parseRules(`
resp_header X-Response-Header {
error 405 "error"
}
`, &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.StatusMethodNotAllowed, w.Code)
assert.Equal(t, "error\n", w.Body.String())
})
t.Run("with_value", func(t *testing.T) {
var rules Rules
err := parseRules(`
resp_header X-Response-Header "response header" {
error 405 "error"
}
`, &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.StatusMethodNotAllowed, w.Code)
assert.Equal(t, "error\n", w.Body.String())
})
t.Run("with_value_not_matched", func(t *testing.T) {
var rules Rules
err := parseRules(`
resp_header X-Response-Header "not-matched" {
error 405 "error"
}
`, &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, "processed", w.Body.String())
})
}
func TestHTTPFlow_ComplexRuleCombinations(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "complex processed")
})
var rules Rules
err := parseRules(`
path glob(/api/admin/*) &
header Authorization &
method POST {
set resp_header X-Access-Level "admin"
set resp_header X-API-Version "v1"
}
path glob(/api/users/*) & method GET {
set resp_header X-Access-Level "user"
set resp_header X-API-Version "v1"
}
path glob(/api/public/*) & method GET {
set resp_header X-Access-Level "public"
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
// Test admin API (should match first rule)
req1 := httptest.NewRequest(http.MethodPost, "/api/admin/users", nil)
req1.Header.Set("Authorization", "Bearer token")
w1 := httptest.NewRecorder()
handler.ServeHTTP(w1, req1)
assert.Equal(t, http.StatusOK, w1.Code)
assert.Equal(t, "admin", w1.Header().Get("X-Access-Level"))
assert.Equal(t, "v1", w1.Header()["X-API-Version"][0])
// Test user API (should match second rule)
req2 := httptest.NewRequest(http.MethodGet, "/api/users/profile", nil)
w2 := httptest.NewRecorder()
handler.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
assert.Equal(t, "user", w2.Header().Get("X-Access-Level"))
assert.Equal(t, "v1", w2.Header()["X-API-Version"][0])
// Test public API (should match third rule)
req3 := httptest.NewRequest(http.MethodGet, "/api/public/info", nil)
w3 := httptest.NewRecorder()
handler.ServeHTTP(w3, req3)
assert.Equal(t, http.StatusOK, w3.Code)
assert.Equal(t, "public", w3.Header().Get("X-Access-Level"))
assert.Empty(t, w3.Header()["X-API-Version"])
}
func TestHTTPFlow_ResponseModifier(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "original response")
})
var rules Rules
err := parseRules(`{
set resp_header X-Modified "true"
set resp_body "Modified: $req_method $req_path"
}`, &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, "true", w.Header().Get("X-Modified"))
assert.Equal(t, "Modified: GET /test\n", w.Body.String())
}
func TestHTTPFlow_RequireBasicAuth_Challenge(t *testing.T) {
called := false
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "upstream")
})
var rules Rules
err := parseRules(`
path /protected {
require_basic_auth "My Realm"
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodGet, "/protected", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.False(t, called, "require_basic_auth should terminate before calling upstream")
assert.Equal(t, 401, w.Code)
assert.Contains(t, w.Header().Get("WWW-Authenticate"), "Basic")
assert.Contains(t, w.Header().Get("WWW-Authenticate"), "My Realm")
}
func TestHTTPFlow_NegationMatcher(t *testing.T) {
upstream := mockUpstream(http.StatusOK, "ok")
var rules Rules
err := parseRules(`
!path glob("/public/*") {
set resp_header X-Scope private
}
path glob("/public/*") {
set resp_header X-Scope public
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
t.Run("public", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/public/index.html", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "public", w.Header().Get("X-Scope"))
})
t.Run("private", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "private", w.Header().Get("X-Scope"))
})
}
func TestHTTPFlow_BlockSyntaxCommentsAreIgnored(t *testing.T) {
upstream := mockUpstream(http.StatusOK, "ok")
var rules Rules
err := parseRules(`
path /comment {
// comment with braces { } should be ignored
set resp_header X-Commented ok # trailing comment should be ignored too
set resp_header X-Literal "//not-a-comment" // but this one is a real comment
/* block comment
spanning multiple lines { } */
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodGet, "/comment", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "ok", w.Header().Get("X-Commented"))
assert.Equal(t, "//not-a-comment", w.Header().Get("X-Literal"))
}
func TestHTTPFlow_RemoveResponseHeader_RemovesUpstreamHeader(t *testing.T) {
upstream := mockUpstreamWithHeaders(http.StatusOK, "ok", http.Header{
"X-Secret": []string{"top-secret"},
"X-Keep": []string{"keep"},
})
var rules Rules
err := parseRules(`
{
remove resp_header X-Secret
}
`, &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, "keep", w.Header().Get("X-Keep"))
assert.Empty(t, w.Result().Header.Get("X-Secret"))
}
func TestHTTPFlow_RemoveRequestHeader_BeforeUpstream(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Seen-Secret", r.Header.Get("X-Secret"))
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
})
var rules Rules
err := parseRules(`
{
remove header X-Secret
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Secret", "should-not-reach-upstream")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Empty(t, w.Header().Get("X-Seen-Secret"), "X-Secret should be removed before reaching upstream")
}
func TestHTTPFlow_RewritePreservesQueryString(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "path=%s foo=%s bar=%s", r.URL.Path, r.URL.Query().Get("foo"), r.URL.Query().Get("bar"))
})
var rules Rules
err := parseRules(`
path glob("/api/*") {
rewrite /api/ /v1/
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodGet, "/api/users?foo=1&bar=2", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "path=/v1/users foo=1 bar=2", w.Body.String())
}
func TestHTTPFlow_ResponseModifier_PreservesUpstreamStatus(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, "created")
})
var rules Rules
err := parseRules(`
{
set resp_body "overridden"
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodPost, "/create", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)
assert.Equal(t, "overridden\n", w.Body.String())
}
func TestHTTPFlow_PreTermination_SkipsLaterPreCommands_ButRunsPostOnlyAndPostMatchers(t *testing.T) {
upstreamCalled := false
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamCalled = true
fmt.Fprint(w, "upstream")
})
var rules Rules
err := parseRules(`
path / {
error 403 blocked
}
path / {
set resp_header X-Late should-not-run
}
status 4xx {
set resp_header X-Post true
}
`, &rules)
require.NoError(t, err)
handler := rules.BuildHandler(upstream)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.False(t, upstreamCalled)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Equal(t, "blocked\n", w.Body.String())
assert.Equal(t, "should-not-run", w.Header().Get("X-Late"))
assert.Equal(t, "true", w.Header().Get("X-Post"))
}
func TestHTTPFlow_PostRuleTermination_StopsRemainingCommandsInRule(t *testing.T) {
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
})
var rules Rules
err := parseRules(`
status 200 {
error 500 failed
set resp_header X-After should-not-run
}
`, &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.StatusInternalServerError, w.Code)
assert.Equal(t, "failed\n", w.Body.String())
assert.Empty(t, w.Header().Get("X-After"))
}
func TestHTTPFlow_EnvVarExpansionInDoBody(t *testing.T) {
t.Setenv("GODOXY_TEST_ENV", "env-value")
upstream := mockUpstream(http.StatusOK, "ok")
var rules Rules
err := parseRules(`
{
set resp_header X-From-Env "${GODOXY_TEST_ENV}"
}
`, &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, "env-value", w.Header().Get("X-From-Env"))
}