mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-22 00:59:11 +01:00
* chore(deps): update submodule goutils * docs(http): remove default client from README.md * refactor(rules): introduce block DSL, phase-based execution, and flow validation - add block syntax parser/scanner with nested @blocks and elif/else support - restructure rule execution into explicit pre/post phases with phase flags - classify commands by phase and termination behavior - enforce flow semantics (default rule handling, dead-rule detection) - expand HTTP flow coverage with block + YAML parity tests and benches - refresh rules README/spec and update playground/docs integration - Default rules act as fallback handlers that execute only when no matching non-default rule exists in the pre phase - IfElseBlockCommand now returns early when a condition matches with a nil Do block, instead of falling through to else blocks - Add nil check for auth handler to allow requests when no auth is configured * fix(rules): buffer log output before writing to stdout/stderr * refactor(api/rules): remove IsResponseRule field from ParsedRule and related logic * docs(rules): update examples to use block syntax
305 lines
8.4 KiB
Go
305 lines
8.4 KiB
Go
package rules
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"maps"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/yusing/godoxy/internal/serialization"
|
|
)
|
|
|
|
// mockUpstream creates a simple upstream handler for testing
|
|
func mockUpstream(status int, body string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(status)
|
|
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 parseRules(data string, target *Rules) error {
|
|
_, err := serialization.ConvertString(data, reflect.ValueOf(target))
|
|
return err
|
|
}
|
|
|
|
func TestLogCommand_TemporaryFile(t *testing.T) {
|
|
upstream := mockUpstreamWithHeaders(http.StatusOK, "success response", http.Header{
|
|
"Content-Type": []string{"application/json"},
|
|
})
|
|
|
|
logFile := TestRandomFileName()
|
|
|
|
var rules Rules
|
|
err := parseRules(fmt.Sprintf(`
|
|
default {
|
|
log info %q '$req_method $req_url $status_code $resp_header(Content-Type)'
|
|
}`, logFile), &rules)
|
|
require.NoError(t, err)
|
|
|
|
handler := rules.BuildHandler(upstream)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/users", nil)
|
|
req.Header.Set("User-Agent", "test-agent")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
assert.Equal(t, "success response", w.Body.String())
|
|
|
|
// Read and verify log content
|
|
content := TestFileContent(logFile)
|
|
logContent := string(content)
|
|
|
|
assert.Equal(t, "POST /api/users 200 application/json\n", logContent)
|
|
}
|
|
|
|
func TestLogCommand_StdoutAndStderr(t *testing.T) {
|
|
originalStdout := stdout
|
|
originalStderr := stderr
|
|
var stdoutBuf bytes.Buffer
|
|
var stderrBuf bytes.Buffer
|
|
stdout = noopWriteCloser{&stdoutBuf}
|
|
stderr = noopWriteCloser{&stderrBuf}
|
|
defer func() {
|
|
stdout = originalStdout
|
|
stderr = originalStderr
|
|
}()
|
|
|
|
upstream := mockUpstream(http.StatusOK, "success")
|
|
|
|
var rules Rules
|
|
err := parseRules(`
|
|
default {
|
|
log info /dev/stdout "stdout: $req_method $status_code"
|
|
log error /dev/stderr "stderr: $req_path $status_code"
|
|
}
|
|
`, &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)
|
|
require.Eventually(t, func() bool {
|
|
return strings.Contains(stdoutBuf.String(), "stdout: GET 200") &&
|
|
strings.Contains(stderrBuf.String(), "stderr: /test 200")
|
|
}, time.Second, 10*time.Millisecond)
|
|
assert.Equal(t, 1, strings.Count(stdoutBuf.String(), "stdout: GET 200"))
|
|
assert.Equal(t, 1, strings.Count(stderrBuf.String(), "stderr: /test 200"))
|
|
}
|
|
|
|
func TestLogCommand_DifferentLogLevels(t *testing.T) {
|
|
upstream := mockUpstream(http.StatusNotFound, "not found")
|
|
|
|
infoFile := TestRandomFileName()
|
|
warnFile := TestRandomFileName()
|
|
errorFile := TestRandomFileName()
|
|
|
|
var rules Rules
|
|
err := parseRules(fmt.Sprintf(`
|
|
default {
|
|
log info %s "INFO: $req_method $status_code"
|
|
log warn %s "WARN: $req_path $status_code"
|
|
log error %s "ERROR: $req_method $req_path $status_code"
|
|
}
|
|
`, infoFile, warnFile, errorFile), &rules)
|
|
require.NoError(t, err)
|
|
|
|
handler := rules.BuildHandler(upstream)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/api/resource/123", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
|
|
|
// Verify each log file
|
|
infoContent := TestFileContent(infoFile)
|
|
assert.Equal(t, "INFO: DELETE 404", strings.TrimSpace(string(infoContent)))
|
|
|
|
warnContent := TestFileContent(warnFile)
|
|
assert.Equal(t, "WARN: /api/resource/123 404", strings.TrimSpace(string(warnContent)))
|
|
|
|
errorContent := TestFileContent(errorFile)
|
|
assert.Equal(t, "ERROR: DELETE /api/resource/123 404", strings.TrimSpace(string(errorContent)))
|
|
}
|
|
|
|
func TestLogCommand_TemplateVariables(t *testing.T) {
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Custom-Header", "custom-value")
|
|
w.Header().Set("Content-Length", "42")
|
|
w.WriteHeader(http.StatusCreated)
|
|
w.Write([]byte("created"))
|
|
})
|
|
|
|
tempFile := TestRandomFileName()
|
|
|
|
var rules Rules
|
|
err := parseRules(fmt.Sprintf(`
|
|
default {
|
|
log info %s 'Request: $req_method $req_url Host: $req_host User-Agent: $header(User-Agent) Response: $status_code Custom-Header: $resp_header(X-Custom-Header) Content-Length: $resp_header(Content-Length)'
|
|
}
|
|
`, tempFile), &rules)
|
|
require.NoError(t, err)
|
|
|
|
handler := rules.BuildHandler(upstream)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/api/resource", nil)
|
|
req.Header.Set("User-Agent", "test-client/1.0")
|
|
req.Host = "example.com"
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
assert.Equal(t, http.StatusCreated, w.Code)
|
|
|
|
// Verify log content
|
|
content := TestFileContent(tempFile)
|
|
logContent := strings.TrimSpace(string(content))
|
|
|
|
assert.Equal(t, "Request: PUT /api/resource Host: example.com User-Agent: test-client/1.0 Response: 201 Custom-Header: custom-value Content-Length: 42", logContent)
|
|
}
|
|
|
|
func TestLogCommand_ConditionalLogging(t *testing.T) {
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/error":
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
w.Write([]byte("internal server error"))
|
|
case "/notfound":
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte("not found"))
|
|
default:
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte("success"))
|
|
}
|
|
})
|
|
|
|
successFile := TestRandomFileName()
|
|
errorFile := TestRandomFileName()
|
|
|
|
var rules Rules
|
|
err := parseRules(fmt.Sprintf(`
|
|
status 2xx {
|
|
log info %q "SUCCESS: $req_method $req_path $status_code"
|
|
}
|
|
status 4xx | status 5xx {
|
|
log error %q "ERROR: $req_method $req_path $status_code"
|
|
}
|
|
`, successFile, errorFile), &rules)
|
|
require.NoError(t, err)
|
|
|
|
handler := rules.BuildHandler(upstream)
|
|
|
|
// Test success request
|
|
req1 := httptest.NewRequest(http.MethodGet, "/success", nil)
|
|
w1 := httptest.NewRecorder()
|
|
handler.ServeHTTP(w1, req1)
|
|
assert.Equal(t, http.StatusOK, w1.Code)
|
|
|
|
// Test not found request
|
|
req2 := httptest.NewRequest(http.MethodGet, "/notfound", nil)
|
|
w2 := httptest.NewRecorder()
|
|
handler.ServeHTTP(w2, req2)
|
|
assert.Equal(t, http.StatusNotFound, w2.Code)
|
|
|
|
// Test server error request
|
|
req3 := httptest.NewRequest(http.MethodPost, "/error", nil)
|
|
w3 := httptest.NewRecorder()
|
|
handler.ServeHTTP(w3, req3)
|
|
assert.Equal(t, http.StatusInternalServerError, w3.Code)
|
|
|
|
// Verify success log
|
|
successContent := TestFileContent(successFile)
|
|
successLines := strings.Split(strings.TrimSpace(string(successContent)), "\n")
|
|
assert.Len(t, successLines, 1)
|
|
assert.Equal(t, "SUCCESS: GET /success 200", successLines[0])
|
|
|
|
// Verify error log
|
|
errorContent := TestFileContent(errorFile)
|
|
errorLines := strings.Split(strings.TrimSpace(string(errorContent)), "\n")
|
|
require.Len(t, errorLines, 2)
|
|
assert.Equal(t, "ERROR: GET /notfound 404", errorLines[0])
|
|
assert.Equal(t, "ERROR: POST /error 500", errorLines[1])
|
|
}
|
|
|
|
func TestLogCommand_MultipleLogEntries(t *testing.T) {
|
|
upstream := mockUpstream(http.StatusOK, "response")
|
|
|
|
tempFile := TestRandomFileName()
|
|
|
|
var rules Rules
|
|
err := parseRules(fmt.Sprintf(`
|
|
default {
|
|
log info %q "$req_method $req_path $status_code"
|
|
}
|
|
`, tempFile), &rules)
|
|
require.NoError(t, err)
|
|
|
|
handler := rules.BuildHandler(upstream)
|
|
|
|
// Make multiple requests
|
|
requests := []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodGet, "/users"},
|
|
{http.MethodPost, "/users"},
|
|
{http.MethodPost, "/users/1"},
|
|
{http.MethodDelete, "/users/1"},
|
|
}
|
|
|
|
for _, reqInfo := range requests {
|
|
req := httptest.NewRequest(reqInfo.method, reqInfo.path, nil)
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
assert.Equal(t, http.StatusOK, w.Code)
|
|
}
|
|
|
|
// Verify all requests were logged
|
|
content := TestFileContent(tempFile)
|
|
logContent := strings.TrimSpace(string(content))
|
|
lines := strings.Split(logContent, "\n")
|
|
|
|
assert.Len(t, lines, len(requests))
|
|
|
|
for i, reqInfo := range requests {
|
|
expectedLog := reqInfo.method + " " + reqInfo.path + " 200"
|
|
assert.Equal(t, expectedLog, lines[i])
|
|
}
|
|
}
|
|
|
|
func TestLogCommand_InvalidTemplate(t *testing.T) {
|
|
var rules Rules
|
|
|
|
// Test with invalid template syntax
|
|
err := parseRules(`
|
|
default {
|
|
log info /dev/stdout "$invalid_var"
|
|
}
|
|
`, &rules)
|
|
require.ErrorIs(t, err, ErrUnexpectedVar)
|
|
}
|