diff --git a/Taskfile.yaml b/Taskfile.yaml index e713a4d..7834246 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -39,10 +39,10 @@ tasks: cmds: - "{{.GOLANGCI}} run" - test: - desc: Run Go tests. + e2e: + desc: Run e2e tests cmds: - - go test ./... {{.CLI_ARGS}} + - go test ./e2e/... -v -count=1 {{.CLI_ARGS}} create-bin-dir: desc: Create bin directory. diff --git a/benchmark.sh b/benchmark.sh new file mode 100755 index 0000000..b4c33cd --- /dev/null +++ b/benchmark.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +RUNS=20 +CMD="go run ./cmd/cli -U http://localhost:80 -r 1_000_000 -c 100" + +declare -a times_default +declare -a times_gogcoff + +echo "===== Benchmark: default GC =====" +for i in $(seq 1 $RUNS); do + echo "Run $i/$RUNS ..." + start=$(date +%s%N) + $CMD + end=$(date +%s%N) + elapsed=$(( (end - start) / 1000000 )) # milliseconds + times_default+=("$elapsed") + echo " -> ${elapsed} ms" +done + +echo "" +echo "===== Benchmark: GOGC=off =====" +for i in $(seq 1 $RUNS); do + echo "Run $i/$RUNS ..." + start=$(date +%s%N) + GOGC=off $CMD + end=$(date +%s%N) + elapsed=$(( (end - start) / 1000000 )) + times_gogcoff+=("$elapsed") + echo " -> ${elapsed} ms" +done + +echo "" +echo "============================================" +echo " RESULTS" +echo "============================================" + +echo "" +echo "--- Default GC ---" +sum=0 +for i in $(seq 0 $((RUNS - 1))); do + echo " Run $((i + 1)): ${times_default[$i]} ms" + sum=$((sum + times_default[$i])) +done +avg_default=$((sum / RUNS)) +echo " Average: ${avg_default} ms" + +echo "" +echo "--- GOGC=off ---" +sum=0 +for i in $(seq 0 $((RUNS - 1))); do + echo " Run $((i + 1)): ${times_gogcoff[$i]} ms" + sum=$((sum + times_gogcoff[$i])) +done +avg_gogcoff=$((sum / RUNS)) +echo " Average: ${avg_gogcoff} ms" + +echo "" +echo "--- Comparison ---" +if [ "$avg_default" -gt 0 ]; then + diff=$((avg_default - avg_gogcoff)) + echo " Difference: ${diff} ms (positive = GOGC=off is faster)" +fi +echo "============================================" diff --git a/e2e/basic_test.go b/e2e/basic_test.go new file mode 100644 index 0000000..5e856aa --- /dev/null +++ b/e2e/basic_test.go @@ -0,0 +1,220 @@ +package e2e + +import ( + "strconv" + "testing" +) + +func TestNoArgs(t *testing.T) { + t.Parallel() + res := run() + assertExitCode(t, res, 1) + // With no args and no env vars, validation should fail on required fields + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestHelp(t *testing.T) { + t.Parallel() + for _, flag := range []string{"-h", "-help"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + res := run(flag) + assertContains(t, res.Stdout, "Usage:") + assertContains(t, res.Stdout, "-url") + }) + } +} + +func TestVersion(t *testing.T) { + t.Parallel() + for _, flag := range []string{"-v", "-version"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + res := run(flag) + assertExitCode(t, res, 0) + assertContains(t, res.Stdout, "Version:") + assertContains(t, res.Stdout, "Git Commit:") + }) + } +} + +func TestUnexpectedArgs(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "unexpected") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "Unexpected CLI arguments") +} + +func TestSimpleRequest(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-r", "3", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") + assertResponseCount(t, out, 3) +} + +func TestDryRun(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "5", "-q", "-o", "json", "-z") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "dry-run") + assertResponseCount(t, out, 5) +} + +func TestDryRunDoesNotSendRequests(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "5", "-q", "-o", "json", "-z") + assertExitCode(t, res, 0) + + if cs.requestCount() != 0 { + t.Errorf("dry-run should not send any requests, but server received %d", cs.requestCount()) + } +} + +func TestQuietMode(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + if res.Stderr != "" { + t.Errorf("expected empty stderr in quiet mode, got: %s", res.Stderr) + } +} + +func TestOutputNone(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "none") + assertExitCode(t, res, 0) + + if res.Stdout != "" { + t.Errorf("expected empty stdout with -o none, got: %s", res.Stdout) + } +} + +func TestOutputJSON(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + if out.Responses == nil { + t.Fatal("responses field is nil") + } + if out.Total.Min == "" || out.Total.Max == "" || out.Total.Average == "" { + t.Errorf("total stats are incomplete: %+v", out.Total) + } + if out.Total.P90 == "" || out.Total.P95 == "" || out.Total.P99 == "" { + t.Errorf("total percentiles are incomplete: %+v", out.Total) + } +} + +func TestOutputYAML(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "yaml") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "responses:") + assertContains(t, res.Stdout, "total:") + assertContains(t, res.Stdout, "count:") +} + +func TestOutputTable(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "table") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "Response") + assertContains(t, res.Stdout, "Count") + assertContains(t, res.Stdout, "Min") + assertContains(t, res.Stdout, "P99") +} + +func TestInvalidOutputFormat(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", "-o", "invalid") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "Output") +} + +func TestStatusCodes(t *testing.T) { + t.Parallel() + codes := []int{200, 201, 204, 301, 400, 404, 500, 502} + for _, code := range codes { + t.Run(strconv.Itoa(code), func(t *testing.T) { + t.Parallel() + srv := statusServer(code) + defer srv.Close() + + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, strconv.Itoa(code)) + }) + } +} + +func TestConcurrency(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-r", "10", "-c", "5", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertResponseCount(t, out, 10) +} + +func TestDuration(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + res := run("-U", srv.URL, "-d", "1s", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + count, _ := out.Total.Count.Int64() + if count < 1 { + t.Errorf("expected at least 1 request during 1s duration, got %d", count) + } +} + +func TestRequestsAndDuration(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + // Both -r and -d set: should stop at whichever comes first + res := run("-U", srv.URL, "-r", "3", "-d", "10s", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertResponseCount(t, out, 3) +} diff --git a/e2e/config_file_test.go b/e2e/config_file_test.go new file mode 100644 index 0000000..324012e --- /dev/null +++ b/e2e/config_file_test.go @@ -0,0 +1,401 @@ +package e2e + +import ( + "net/http" + "testing" +) + +func TestConfigFileBasic(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") + assertResponseCount(t, out, 1) +} + +func TestConfigFileWithMethod(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +method: POST +requests: 1 +quiet: true +output: json +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodPost { + t.Errorf("expected method POST from config, got %s", req.Method) + } +} + +func TestConfigFileWithHeaders(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +headers: + - X-Config: config-value +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Config"]; len(v) == 0 || v[0] != "config-value" { + t.Errorf("expected X-Config: config-value, got %v", v) + } +} + +func TestConfigFileWithParams(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +params: + - key1: value1 +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["key1"]; len(v) == 0 || v[0] != "value1" { + t.Errorf("expected key1=value1, got %v", v) + } +} + +func TestConfigFileWithCookies(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +cookies: + - session: abc123 +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Cookies["session"]; !ok || v != "abc123" { + t.Errorf("expected cookie session=abc123, got %v", req.Cookies) + } +} + +func TestConfigFileWithBody(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +method: POST +requests: 1 +quiet: true +output: json +body: "hello from config" +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "hello from config" { + t.Errorf("expected body 'hello from config', got %q", req.Body) + } +} + +func TestConfigFileCLIOverridesScalars(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "http://should-be-overridden.invalid" +requests: 1 +quiet: true +output: json +` + configPath := writeTemp(t, "config.yaml", config) + + // CLI -U should override the config file URL (scalar override) + res := run("-f", configPath, "-U", cs.URL) + assertExitCode(t, res, 0) + assertResponseCount(t, res.jsonOutput(t), 1) + + // Verify it actually hit our server + if cs.requestCount() != 1 { + t.Errorf("expected 1 request to capture server, got %d", cs.requestCount()) + } +} + +func TestConfigFileCLIOverridesMethods(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +method: GET +requests: 4 +quiet: true +output: json +` + configPath := writeTemp(t, "config.yaml", config) + + // CLI -M POST overrides config file's method: GET + res := run("-f", configPath, "-M", "POST") + assertExitCode(t, res, 0) + + for _, r := range cs.allRequests() { + if r.Method != http.MethodPost { + t.Errorf("expected all requests to be POST (CLI overrides config), got %s", r.Method) + } + } +} + +func TestConfigFileInvalidYAML(t *testing.T) { + t.Parallel() + configPath := writeTemp(t, "bad.yaml", `{{{not valid yaml`) + + res := run("-f", configPath) + assertExitCode(t, res, 1) +} + +func TestConfigFileNotFound(t *testing.T) { + t.Parallel() + res := run("-f", "/nonexistent/path/config.yaml") + assertExitCode(t, res, 1) +} + +func TestConfigFileWithDryRun(t *testing.T) { + t.Parallel() + + config := ` +url: "http://example.com" +requests: 3 +quiet: true +output: json +dryRun: true +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "dry-run") + assertResponseCount(t, out, 3) +} + +func TestConfigFileWithConcurrency(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 6 +concurrency: 3 +quiet: true +output: json +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertResponseCount(t, out, 6) +} + +func TestConfigFileNestedIncludes(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Create inner config + innerConfig := ` +headers: + - X-Inner: from-inner +` + innerPath := writeTemp(t, "inner.yaml", innerConfig) + + // Create outer config that includes inner + outerConfig := ` +configFile: "` + innerPath + `" +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +` + outerPath := writeTemp(t, "outer.yaml", outerConfig) + + res := run("-f", outerPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Inner"]; len(v) == 0 || v[0] != "from-inner" { + t.Errorf("expected X-Inner: from-inner from nested config, got %v", v) + } +} + +func TestConfigFileFromHTTPURL(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +headers: + - X-Remote-Config: yes +` + // Serve config via HTTP + configServer := statusServerWithBody(config) + defer configServer.Close() + + res := run("-f", configServer.URL) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Remote-Config"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Remote-Config: yes from HTTP config, got %v", v) + } +} + +func TestConfigFileMultiValueHeaders(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +headers: + - X-Multi: + - val1 + - val2 +` + configPath := writeTemp(t, "config.yaml", config) + + // With multiple values, sarin cycles through them (random start). + // With -r 1, we should see exactly one of them. + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + v, ok := req.Headers["X-Multi"] + if !ok || len(v) == 0 { + t.Fatalf("expected X-Multi header, got headers: %v", req.Headers) + } + if v[0] != "val1" && v[0] != "val2" { + t.Errorf("expected X-Multi to be val1 or val2, got %v", v) + } +} + +func TestConfigFileWithTimeout(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +timeout: 5s +quiet: true +output: json +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + assertResponseCount(t, res.jsonOutput(t), 1) +} + +func TestConfigFileWithInsecure(t *testing.T) { + t.Parallel() + + config := ` +url: "http://example.com" +requests: 1 +insecure: true +quiet: true +output: json +dryRun: true +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) +} + +func TestConfigFileWithLuaScript(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + scriptContent := `function transform(req) req.headers["X-Config-Lua"] = {"yes"} return req end` + scriptPath := writeTemp(t, "script.lua", scriptContent) + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +lua: "@` + scriptPath + `" +` + configPath := writeTemp(t, "config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Config-Lua"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Config-Lua: yes, got %v", v) + } +} diff --git a/e2e/config_merge_test.go b/e2e/config_merge_test.go new file mode 100644 index 0000000..1822702 --- /dev/null +++ b/e2e/config_merge_test.go @@ -0,0 +1,282 @@ +package e2e + +import ( + "net/http" + "testing" +) + +// --- Multiple config files --- + +func TestMultipleConfigFiles(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config1 := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +headers: + - X-From-File1: yes +` + config2 := ` +headers: + - X-From-File2: yes +` + path1 := writeTemp(t, "merge1.yaml", config1) + path2 := writeTemp(t, "merge2.yaml", config2) + + res := run("-f", path1, "-f", path2) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-From-File1"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-From-File1: yes, got %v", v) + } + if v := req.Headers["X-From-File2"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-From-File2: yes, got %v", v) + } +} + +func TestMultipleConfigFilesScalarOverride(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Second config file overrides URL from first + config1 := ` +url: "http://should-be-overridden.invalid" +requests: 1 +quiet: true +output: json +` + config2 := ` +url: "` + cs.URL + `" +` + path1 := writeTemp(t, "merge_scalar1.yaml", config1) + path2 := writeTemp(t, "merge_scalar2.yaml", config2) + + res := run("-f", path1, "-f", path2) + assertExitCode(t, res, 0) + + if cs.requestCount() != 1 { + t.Errorf("expected request to go to second config's URL, got %d requests", cs.requestCount()) + } +} + +// --- Three-way merge: env + config file + CLI --- + +func TestThreeWayMergePriority(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +method: PUT +headers: + - X-From-Config: config-value +` + configPath := writeTemp(t, "three_way.yaml", config) + + // ENV sets URL and header, config file sets method and header, CLI overrides URL + res := runWithEnv(map[string]string{ + "SARIN_HEADER": "X-From-Env: env-value", + }, "-U", cs.URL, "-r", "1", "-q", "-o", "json", "-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + // Method should be PUT from config (not default GET) + if req.Method != http.MethodPut { + t.Errorf("expected method PUT from config, got %s", req.Method) + } + // Header from config file should be present + if v := req.Headers["X-From-Config"]; len(v) == 0 || v[0] != "config-value" { + t.Errorf("expected X-From-Config from config file, got %v", v) + } + // Header from env should be present + if v := req.Headers["X-From-Env"]; len(v) == 0 || v[0] != "env-value" { + t.Errorf("expected X-From-Env from env, got %v", v) + } +} + +// --- Config file nesting depth --- + +func TestConfigFileNestedMaxDepth(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Create a chain of 12 config files (exceeds max depth of 10) + // The innermost file has the actual URL config + // When depth is exceeded, inner files are silently ignored + + files := make([]string, 12) + + // Innermost file (index 11) - has the real config + files[11] = writeTemp(t, "depth11.yaml", ` +url: "`+cs.URL+`" +requests: 1 +quiet: true +output: json +headers: + - X-Depth: deep +`) + + // Chain each file to include the next one + for i := 10; i >= 0; i-- { + content := `configFile: "` + files[i+1] + `"` + files[i] = writeTemp(t, "depth"+string(rune('0'+i))+".yaml", content) + } + + // The outermost file: this will recurse but max depth will prevent + // reaching the innermost file with the URL + res := run("-f", files[0], "-q") + // This should fail because URL is never reached (too deep) + assertExitCode(t, res, 1) +} + +// --- YAML format flexibility --- + +func TestConfigFileParamsMapFormat(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +params: + key1: value1 + key2: value2 +` + configPath := writeTemp(t, "params_map.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["key1"]; len(v) == 0 || v[0] != "value1" { + t.Errorf("expected key1=value1, got %v", v) + } + if v := req.Query["key2"]; len(v) == 0 || v[0] != "value2" { + t.Errorf("expected key2=value2, got %v", v) + } +} + +func TestConfigFileHeadersMapFormat(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +headers: + X-Map-A: map-val-a + X-Map-B: map-val-b +` + configPath := writeTemp(t, "headers_map.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Map-A"]; len(v) == 0 || v[0] != "map-val-a" { + t.Errorf("expected X-Map-A: map-val-a, got %v", v) + } + if v := req.Headers["X-Map-B"]; len(v) == 0 || v[0] != "map-val-b" { + t.Errorf("expected X-Map-B: map-val-b, got %v", v) + } +} + +func TestConfigFileCookiesMapFormat(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +cookies: + sess: abc + token: xyz +` + configPath := writeTemp(t, "cookies_map.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Cookies["sess"]; !ok || v != "abc" { + t.Errorf("expected cookie sess=abc, got %v", req.Cookies) + } + if v, ok := req.Cookies["token"]; !ok || v != "xyz" { + t.Errorf("expected cookie token=xyz, got %v", req.Cookies) + } +} + +func TestConfigFileMultipleBodies(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 10 +concurrency: 1 +method: POST +quiet: true +output: json +body: + - "body-one" + - "body-two" +` + configPath := writeTemp(t, "multi_body.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + bodies := map[string]bool{} + for _, req := range cs.allRequests() { + bodies[req.Body] = true + } + if !bodies["body-one"] || !bodies["body-two"] { + t.Errorf("expected both body-one and body-two to appear, got %v", bodies) + } +} + +func TestConfigFileMultipleMethods(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 10 +concurrency: 1 +quiet: true +output: json +method: + - GET + - POST +` + configPath := writeTemp(t, "multi_method.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + methods := map[string]bool{} + for _, req := range cs.allRequests() { + methods[req.Method] = true + } + if !methods["GET"] || !methods["POST"] { + t.Errorf("expected both GET and POST, got %v", methods) + } +} diff --git a/e2e/config_nested_http_test.go b/e2e/config_nested_http_test.go new file mode 100644 index 0000000..ef3727e --- /dev/null +++ b/e2e/config_nested_http_test.go @@ -0,0 +1,37 @@ +package e2e + +import ( + "testing" +) + +func TestConfigFileNestedHTTPInclude(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Inner config served via HTTP + innerConfig := ` +headers: + - X-From-HTTP-Nested: yes +` + innerServer := statusServerWithBody(innerConfig) + defer innerServer.Close() + + // Outer config references the inner config via HTTP URL + outerConfig := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +configFile: "` + innerServer.URL + `" +` + outerPath := writeTemp(t, "outer_http.yaml", outerConfig) + + res := run("-f", outerPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-From-Http-Nested"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-From-Http-Nested: yes from nested HTTP config, got %v", v) + } +} diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..0f15936 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,316 @@ +package e2e + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +var binaryPath string + +func TestMain(m *testing.M) { + // Build the binary once before all tests. + tmpDir, err := os.MkdirTemp("", "sarin-e2e-*") + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create temp dir: %v\n", err) + os.Exit(1) + } + binaryPath = filepath.Join(tmpDir, "sarin") + if runtime.GOOS == "windows" { + binaryPath += ".exe" + } + + cmd := exec.Command("go", "build", "-o", binaryPath, "../cmd/cli/main.go") + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "failed to build binary: %v\n", err) + os.Exit(1) + } + + code := m.Run() + os.RemoveAll(tmpDir) + os.Exit(code) +} + +// --- Result type --- + +// runResult holds the output of a sarin binary execution. +type runResult struct { + Stdout string + Stderr string + ExitCode int +} + +// jsonOutput parses the stdout as JSON output from sarin. +// Fails the test if parsing fails. +func (r runResult) jsonOutput(t *testing.T) outputData { + t.Helper() + var out outputData + if err := json.Unmarshal([]byte(r.Stdout), &out); err != nil { + t.Fatalf("failed to parse JSON output: %v\nstdout: %s", err, r.Stdout) + } + return out +} + +// --- JSON output structures --- + +type responseStat struct { + Count json.Number `json:"count"` + Min string `json:"min"` + Max string `json:"max"` + Average string `json:"average"` + P90 string `json:"p90"` + P95 string `json:"p95"` + P99 string `json:"p99"` +} + +type outputData struct { + Responses map[string]responseStat `json:"responses"` + Total responseStat `json:"total"` +} + +// --- echoResponse is the JSON structure returned by echoServer --- + +type echoResponse struct { + Method string `json:"method"` + Path string `json:"path"` + Query map[string][]string `json:"query"` + Headers map[string][]string `json:"headers"` + Cookies map[string]string `json:"cookies"` + Body string `json:"body"` +} + +// --- Helpers --- + +// run executes the sarin binary with the given args and returns the result. +func run(args ...string) runResult { + cmd := exec.Command(binaryPath, args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + + return runResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + } +} + +// runWithEnv executes the sarin binary with the given args and environment variables. +func runWithEnv(env map[string]string, args ...string) runResult { + cmd := exec.Command(binaryPath, args...) + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Start with a clean env, then add the requested vars + cmd.Env = os.Environ() + for k, v := range env { + cmd.Env = append(cmd.Env, k+"="+v) + } + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + + return runResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: exitCode, + } +} + +// startProcess starts the sarin binary and returns the exec.Cmd without waiting. +// The caller is responsible for managing the process lifecycle. +func startProcess(args ...string) (*exec.Cmd, *strings.Builder) { + cmd := exec.Command(binaryPath, args...) + var stdout strings.Builder + cmd.Stdout = &stdout + return cmd, &stdout +} + +// slowServer returns a server that delays each response by the given duration. +func slowServer(delay time.Duration) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(delay) + w.WriteHeader(http.StatusOK) + })) +} + +// echoServer starts an HTTP test server that echoes request details back as JSON. +// The response includes method, path, headers, query params, cookies, and body. +func echoServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + cookies := make(map[string]string) + for _, c := range r.Cookies() { + cookies[c.Name] = c.Value + } + + resp := echoResponse{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Headers: r.Header, + Cookies: cookies, + Body: string(body), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) +} + +// captureServer records every request it receives and responds with 200. +// Use lastRequest() to inspect the most recent request. +type captureServer struct { + *httptest.Server + + mu sync.Mutex + requests []echoResponse +} + +func newCaptureServer() *captureServer { + cs := &captureServer{} + cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + cookies := make(map[string]string) + for _, c := range r.Cookies() { + cookies[c.Name] = c.Value + } + + cs.mu.Lock() + cs.requests = append(cs.requests, echoResponse{ + Method: r.Method, + Path: r.URL.Path, + Query: r.URL.Query(), + Headers: r.Header, + Cookies: cookies, + Body: string(body), + }) + cs.mu.Unlock() + + w.WriteHeader(http.StatusOK) + })) + return cs +} + +func (cs *captureServer) lastRequest() echoResponse { + cs.mu.Lock() + defer cs.mu.Unlock() + if len(cs.requests) == 0 { + return echoResponse{} + } + return cs.requests[len(cs.requests)-1] +} + +func (cs *captureServer) allRequests() []echoResponse { + cs.mu.Lock() + defer cs.mu.Unlock() + copied := make([]echoResponse, len(cs.requests)) + copy(copied, cs.requests) + return copied +} + +func (cs *captureServer) requestCount() int { + cs.mu.Lock() + defer cs.mu.Unlock() + return len(cs.requests) +} + +// statusServer returns a server that always responds with the given status code. +func statusServer(code int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(code) + })) +} + +// statusServerWithBody returns a server that responds with 200 and the given body. +func statusServerWithBody(body string) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(body)) + })) +} + +// writeTemp creates a temporary file with the given content and returns its path. +// The file is automatically cleaned up when the test finishes. +func writeTemp(t *testing.T, name, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + return path +} + +// --- Assertion helpers --- + +func assertExitCode(t *testing.T, res runResult, want int) { + t.Helper() + if res.ExitCode != want { + t.Errorf("expected exit code %d, got %d\nstdout: %s\nstderr: %s", want, res.ExitCode, res.Stdout, res.Stderr) + } +} + +func assertContains(t *testing.T, s, substr string) { + t.Helper() + if !strings.Contains(s, substr) { + t.Errorf("expected output to contain %q, got:\n%s", substr, s) + } +} + +func assertResponseCount(t *testing.T, out outputData, wantTotal int) { + t.Helper() + got, err := out.Total.Count.Int64() + if err != nil { + t.Fatalf("failed to parse total count: %v", err) + } + if got != int64(wantTotal) { + t.Errorf("expected total count %d, got %d", wantTotal, got) + } +} + +func assertHasResponseKey(t *testing.T, out outputData, key string) { + t.Helper() + if _, ok := out.Responses[key]; !ok { + t.Errorf("expected %q in responses, got keys: %v", key, responseKeys(out)) + } +} + +func responseKeys(out outputData) []string { + keys := make([]string, 0, len(out.Responses)) + for k := range out.Responses { + keys = append(keys, k) + } + return keys +} diff --git a/e2e/env_errors_test.go b/e2e/env_errors_test.go new file mode 100644 index 0000000..52835b3 --- /dev/null +++ b/e2e/env_errors_test.go @@ -0,0 +1,87 @@ +package e2e + +import ( + "testing" +) + +func TestEnvInvalidConcurrency(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_CONCURRENCY": "not-a-number", + }, "-q") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "invalid value for unsigned integer") +} + +func TestEnvInvalidRequests(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "abc", + }, "-q") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "invalid value for unsigned integer") +} + +func TestEnvInvalidDuration(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_DURATION": "not-a-duration", + }, "-q") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "invalid value duration") +} + +func TestEnvInvalidTimeout(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_TIMEOUT": "xyz", + }, "-q") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "invalid value duration") +} + +func TestEnvInvalidInsecure(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_INSECURE": "maybe", + }, "-q") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "invalid value for boolean") +} + +func TestEnvInvalidDryRun(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_DRY_RUN": "yes", + }, "-q") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "invalid value for boolean") +} + +func TestEnvInvalidShowConfig(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_SHOW_CONFIG": "nope", + }, "-q") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "invalid value for boolean") +} diff --git a/e2e/env_test.go b/e2e/env_test.go new file mode 100644 index 0000000..8ca1d13 --- /dev/null +++ b/e2e/env_test.go @@ -0,0 +1,348 @@ +package e2e + +import ( + "net/http" + "testing" +) + +func TestEnvURL(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") + assertResponseCount(t, out, 1) +} + +func TestEnvMethod(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_METHOD": "POST", + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodPost { + t.Errorf("expected method POST from env, got %s", req.Method) + } +} + +func TestEnvConcurrency(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "6", + "SARIN_CONCURRENCY": "3", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertResponseCount(t, out, 6) +} + +func TestEnvDuration(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_DURATION": "1s", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + count, _ := out.Total.Count.Int64() + if count < 1 { + t.Errorf("expected at least 1 request during 1s, got %d", count) + } +} + +func TestEnvTimeout(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_TIMEOUT": "5s", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + assertResponseCount(t, res.jsonOutput(t), 1) +} + +func TestEnvHeader(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_HEADER": "X-From-Env: env-value", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-From-Env"]; len(v) == 0 || v[0] != "env-value" { + t.Errorf("expected X-From-Env: env-value, got %v", v) + } +} + +func TestEnvParam(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_PARAM": "env_key=env_val", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["env_key"]; len(v) == 0 || v[0] != "env_val" { + t.Errorf("expected env_key=env_val, got %v", v) + } +} + +func TestEnvCookie(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_COOKIE": "env_session=env_abc", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Cookies["env_session"]; !ok || v != "env_abc" { + t.Errorf("expected cookie env_session=env_abc, got %v", req.Cookies) + } +} + +func TestEnvBody(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_METHOD": "POST", + "SARIN_REQUESTS": "1", + "SARIN_BODY": "env-body-content", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "env-body-content" { + t.Errorf("expected body 'env-body-content', got %q", req.Body) + } +} + +func TestEnvDryRun(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "3", + "SARIN_DRY_RUN": "true", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "dry-run") + assertResponseCount(t, out, 3) +} + +func TestEnvInsecure(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_INSECURE": "true", + "SARIN_DRY_RUN": "true", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) +} + +func TestEnvOutputNone(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "none", + }) + assertExitCode(t, res, 0) + + if res.Stdout != "" { + t.Errorf("expected empty stdout with output=none, got: %s", res.Stdout) + } +} + +func TestEnvConfigFile(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 1 +quiet: true +output: json +headers: + - X-From-Env-Config: yes +` + configPath := writeTemp(t, "env_config.yaml", config) + + res := runWithEnv(map[string]string{ + "SARIN_CONFIG_FILE": configPath, + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-From-Env-Config"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-From-Env-Config: yes, got %v", v) + } +} + +func TestEnvCLIOverridesEnv(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // CLI should take priority over env vars + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://should-be-overridden.invalid", + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }, "-U", cs.URL) + assertExitCode(t, res, 0) + + if cs.requestCount() != 1 { + t.Errorf("expected CLI URL to override env, but server got %d requests", cs.requestCount()) + } +} + +func TestEnvInvalidBool(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "not-a-bool", + }) + assertExitCode(t, res, 1) +} + +func TestEnvLuaScript(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.headers["X-Env-Lua"] = {"yes"} return req end` + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + "SARIN_LUA": script, + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Env-Lua"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Env-Lua: yes, got %v", v) + } +} + +func TestEnvJsScript(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) { req.headers["X-Env-Js"] = ["yes"]; return req; }` + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + "SARIN_JS": script, + }) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Env-Js"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Env-Js: yes, got %v", v) + } +} + +func TestEnvValues(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": cs.URL, + "SARIN_REQUESTS": "1", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + "SARIN_VALUES": "MY_KEY=my_val", + }, "-H", "X-Val: {{ .Values.MY_KEY }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Val"]; len(v) == 0 || v[0] != "my_val" { + t.Errorf("expected X-Val: my_val, got %v", v) + } +} diff --git a/e2e/formdata_test.go b/e2e/formdata_test.go new file mode 100644 index 0000000..4360b4d --- /dev/null +++ b/e2e/formdata_test.go @@ -0,0 +1,149 @@ +package e2e + +import ( + "encoding/base64" + "testing" +) + +func TestBodyFormDataSimple(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ body_FormData "name" "John" "age" "30" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + // Body should contain multipart form data + assertContains(t, req.Body, "name") + assertContains(t, req.Body, "John") + assertContains(t, req.Body, "age") + assertContains(t, req.Body, "30") + + // Content-Type should be multipart/form-data + ct := req.Headers["Content-Type"] + if len(ct) == 0 { + t.Fatal("expected Content-Type header for form data") + } + assertContains(t, ct[0], "multipart/form-data") +} + +func TestBodyFormDataWithFileUpload(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Create a temp file to upload + filePath := writeTemp(t, "upload.txt", "file content here") + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ body_FormData "description" "test file" "document" "@`+filePath+`" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + assertContains(t, req.Body, "description") + assertContains(t, req.Body, "test file") + assertContains(t, req.Body, "file content here") + assertContains(t, req.Body, "upload.txt") +} + +func TestBodyFormDataWithRemoteFile(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Serve a file via HTTP + fileServer := statusServerWithBody("remote file content") + defer fileServer.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ body_FormData "file" "@`+fileServer.URL+`" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + assertContains(t, req.Body, "remote file content") +} + +func TestBodyFormDataEscapedAt(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // @@ should send literal @ prefixed value + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ body_FormData "email" "@@user@example.com" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + assertContains(t, req.Body, "@user@example.com") +} + +func TestBodyFormDataOddArgsError(t *testing.T) { + t.Parallel() + + // Odd number of args should cause an error + res := run("-U", "http://example.com", "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ body_FormData "key_only" }}`) + // This should either fail at validation or produce an error in output + // The template is valid syntax but body_FormData returns an error at runtime + if res.ExitCode == 0 { + out := res.jsonOutput(t) + // If it didn't exit 1, the error should show up as a response key + if _, ok := out.Responses["200"]; ok { + t.Error("expected error for odd form data args, but got 200") + } + } +} + +func TestFileBase64(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + content := "hello base64 world" + filePath := writeTemp(t, "base64test.txt", content) + expected := base64.StdEncoding.EncodeToString([]byte(content)) + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ file_Base64 "`+filePath+`" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != expected { + t.Errorf("expected base64 %q, got %q", expected, req.Body) + } +} + +func TestFileBase64RemoteFile(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + content := "remote base64 content" + fileServer := statusServerWithBody(content) + defer fileServer.Close() + + expected := base64.StdEncoding.EncodeToString([]byte(content)) + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ file_Base64 "`+fileServer.URL+`" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != expected { + t.Errorf("expected base64 %q, got %q", expected, req.Body) + } +} + +func TestBodyFormDataMultipleRequests(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "3", "-c", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ body_FormData "id" "{{ fakeit_UUID }}" }}`) + assertExitCode(t, res, 0) + + assertResponseCount(t, res.jsonOutput(t), 3) +} diff --git a/e2e/multi_value_test.go b/e2e/multi_value_test.go new file mode 100644 index 0000000..15e4ba9 --- /dev/null +++ b/e2e/multi_value_test.go @@ -0,0 +1,226 @@ +package e2e + +import ( + "net/http" + "testing" +) + +// --- CLI: multiple same-key values are all sent in every request --- + +func TestMultipleHeadersSameKeyCLI(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-H", "X-Multi: value1", "-H", "X-Multi: value2") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals := req.Headers["X-Multi"] + if len(vals) < 2 { + t.Fatalf("expected 2 values for X-Multi, got %v", vals) + } + found := map[string]bool{} + for _, v := range vals { + found[v] = true + } + if !found["value1"] || !found["value2"] { + t.Errorf("expected both value1 and value2, got %v", vals) + } +} + +func TestMultipleParamsSameKeyCLI(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-P", "color=red", "-P", "color=blue") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals := req.Query["color"] + if len(vals) < 2 { + t.Fatalf("expected 2 values for color param, got %v", vals) + } + found := map[string]bool{} + for _, v := range vals { + found[v] = true + } + if !found["red"] || !found["blue"] { + t.Errorf("expected both red and blue, got %v", vals) + } +} + +func TestMultipleCookiesSameKeyCLI(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-C", "token=abc", "-C", "token=def") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + cookieHeader := "" + if v := req.Headers["Cookie"]; len(v) > 0 { + cookieHeader = v[0] + } + assertContains(t, cookieHeader, "token=abc") + assertContains(t, cookieHeader, "token=def") +} + +// --- Config file: multiple values for same key cycle across requests --- + +func TestMultipleHeadersSameKeyYAMLCycle(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 20 +concurrency: 1 +quiet: true +output: json +headers: + - X-Multi: [val-a, val-b] +` + configPath := writeTemp(t, "multi_header.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + seen := map[string]bool{} + for _, req := range cs.allRequests() { + if vals := req.Headers["X-Multi"]; len(vals) > 0 { + seen[vals[0]] = true + } + } + if !seen["val-a"] { + t.Error("expected val-a to appear in some requests") + } + if !seen["val-b"] { + t.Error("expected val-b to appear in some requests") + } +} + +func TestMultipleParamsSameKeyYAMLCycle(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + config := ` +url: "` + cs.URL + `" +requests: 20 +concurrency: 1 +quiet: true +output: json +params: + - tag: [go, rust] +` + configPath := writeTemp(t, "multi_param.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + seen := map[string]bool{} + for _, req := range cs.allRequests() { + if vals := req.Query["tag"]; len(vals) > 0 { + seen[vals[0]] = true + } + } + if !seen["go"] { + t.Error("expected 'go' to appear in some requests") + } + if !seen["rust"] { + t.Error("expected 'rust' to appear in some requests") + } +} + +// --- Multiple bodies cycle --- + +func TestMultipleBodiesCycle(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "10", "-c", "1", "-M", "POST", "-q", "-o", "json", + "-B", "body-alpha", "-B", "body-beta") + assertExitCode(t, res, 0) + + bodies := map[string]bool{} + for _, req := range cs.allRequests() { + bodies[req.Body] = true + } + if !bodies["body-alpha"] { + t.Error("expected body-alpha to appear in requests") + } + if !bodies["body-beta"] { + t.Error("expected body-beta to appear in requests") + } +} + +// --- Multiple methods cycling --- + +func TestMultipleMethodsCycleDistribution(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "20", "-c", "1", "-q", "-o", "json", + "-M", "GET", "-M", "POST", "-M", "PUT") + assertExitCode(t, res, 0) + + methods := map[string]int{} + for _, req := range cs.allRequests() { + methods[req.Method]++ + } + if methods["GET"] == 0 { + t.Error("expected GET to appear") + } + if methods["POST"] == 0 { + t.Error("expected POST to appear") + } + if methods["PUT"] == 0 { + t.Error("expected PUT to appear") + } +} + +// --- Template in method --- + +func TestTemplateInMethod(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-M", `{{ strings_ToUpper "post" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodPost { + t.Errorf("expected method POST from template, got %s", req.Method) + } +} + +// --- Template in cookie value --- + +func TestTemplateInCookie(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-C", `session={{ fakeit_UUID }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Cookies["session"] == "" { + t.Error("expected session cookie with UUID value, got empty") + } + if len(req.Cookies["session"]) < 10 { + t.Errorf("expected UUID-like session cookie, got %q", req.Cookies["session"]) + } +} diff --git a/e2e/output_test.go b/e2e/output_test.go new file mode 100644 index 0000000..1231b67 --- /dev/null +++ b/e2e/output_test.go @@ -0,0 +1,198 @@ +package e2e + +import ( + "encoding/json" + "strings" + "testing" + + "go.yaml.in/yaml/v4" +) + +// --- JSON output structure verification --- + +func TestJSONOutputHasStatFields(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "3", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + + // Verify total has all stat fields + if out.Total.Count.String() != "3" { + t.Errorf("expected count 3, got %s", out.Total.Count.String()) + } + if out.Total.Min == "" { + t.Error("expected min to be non-empty") + } + if out.Total.Max == "" { + t.Error("expected max to be non-empty") + } + if out.Total.Average == "" { + t.Error("expected average to be non-empty") + } + if out.Total.P90 == "" { + t.Error("expected p90 to be non-empty") + } + if out.Total.P95 == "" { + t.Error("expected p95 to be non-empty") + } + if out.Total.P99 == "" { + t.Error("expected p99 to be non-empty") + } +} + +func TestJSONOutputResponseStatFields(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "5", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + stat, ok := out.Responses["200"] + if !ok { + t.Fatal("expected 200 in responses") + } + + if stat.Count.String() != "5" { + t.Errorf("expected response count 5, got %s", stat.Count.String()) + } + if stat.Min == "" || stat.Max == "" || stat.Average == "" { + t.Error("expected min/max/average to be non-empty") + } +} + +func TestJSONOutputMultipleStatusCodes(t *testing.T) { + t.Parallel() + + // Create servers with different status codes + srv200 := statusServer(200) + defer srv200.Close() + srv404 := statusServer(404) + defer srv404.Close() + + // We can only target one URL, so use a single server + // Instead, test that dry-run produces the expected structure + res := run("-U", "http://example.com", "-r", "3", "-z", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + // dry-run should have "dry-run" key + stat := out.Responses["dry-run"] + if stat.Count.String() != "3" { + t.Errorf("expected dry-run count 3, got %s", stat.Count.String()) + } +} + +func TestJSONOutputIsValidJSON(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + // Verify it's valid JSON + var raw map[string]any + if err := json.Unmarshal([]byte(res.Stdout), &raw); err != nil { + t.Fatalf("stdout is not valid JSON: %v", err) + } + + // Verify top-level structure + if _, ok := raw["responses"]; !ok { + t.Error("expected 'responses' key in JSON output") + } + if _, ok := raw["total"]; !ok { + t.Error("expected 'total' key in JSON output") + } +} + +// --- YAML output structure verification --- + +func TestYAMLOutputIsValidYAML(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "yaml") + assertExitCode(t, res, 0) + + var raw map[string]any + if err := yaml.Unmarshal([]byte(res.Stdout), &raw); err != nil { + t.Fatalf("stdout is not valid YAML: %v", err) + } + + if _, ok := raw["responses"]; !ok { + t.Error("expected 'responses' key in YAML output") + } + if _, ok := raw["total"]; !ok { + t.Error("expected 'total' key in YAML output") + } +} + +func TestYAMLOutputHasStatFields(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "yaml") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "count:") + assertContains(t, res.Stdout, "min:") + assertContains(t, res.Stdout, "max:") + assertContains(t, res.Stdout, "average:") + assertContains(t, res.Stdout, "p90:") + assertContains(t, res.Stdout, "p95:") + assertContains(t, res.Stdout, "p99:") +} + +// --- Table output content verification --- + +func TestTableOutputContainsHeaders(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "table") + assertExitCode(t, res, 0) + + // Table should contain column headers + assertContains(t, res.Stdout, "Response") + assertContains(t, res.Stdout, "Count") + assertContains(t, res.Stdout, "Min") + assertContains(t, res.Stdout, "Max") +} + +func TestTableOutputContainsStatusCode(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "table") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "200") +} + +// --- Version output format --- + +func TestVersionOutputFormat(t *testing.T) { + t.Parallel() + + res := run("-v") + assertExitCode(t, res, 0) + + lines := strings.Split(strings.TrimSpace(res.Stdout), "\n") + if len(lines) < 4 { + t.Fatalf("expected at least 4 lines in version output, got %d: %s", len(lines), res.Stdout) + } + assertContains(t, lines[0], "Version:") + assertContains(t, lines[1], "Git Commit:") + assertContains(t, lines[2], "Build Date:") + assertContains(t, lines[3], "Go Version:") +} diff --git a/e2e/proxy_test.go b/e2e/proxy_test.go new file mode 100644 index 0000000..6e5ec84 --- /dev/null +++ b/e2e/proxy_test.go @@ -0,0 +1,103 @@ +package e2e + +import ( + "testing" +) + +// Note: We can't easily test actual proxy connections in E2E tests without +// setting up real proxy servers. These tests verify the validation and +// error handling around proxy configuration. + +func TestProxyValidSchemes(t *testing.T) { + t.Parallel() + + // Valid proxy scheme should not cause a validation error + // (will fail at connection time since no proxy is running, but should pass validation) + for _, scheme := range []string{"http", "https", "socks5", "socks5h"} { + t.Run(scheme, func(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-z", "-q", "-o", "json", + "-X", scheme+"://127.0.0.1:9999") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "dry-run") + }) + } +} + +func TestProxyInvalidScheme(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json", + "-X", "ftp://proxy.example.com:8080") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestMultipleProxiesDryRun(t *testing.T) { + t.Parallel() + + // Multiple proxies with dry-run to verify they're accepted + res := run("-U", "http://example.com", "-r", "3", "-z", "-q", "-o", "json", + "-X", "http://127.0.0.1:8080", + "-X", "http://127.0.0.1:8081") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertResponseCount(t, out, 3) +} + +func TestProxyConnectionFailure(t *testing.T) { + t.Parallel() + + // Use a proxy that doesn't exist — should get a connection error + res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json", + "-X", "http://127.0.0.1:1") + // The process should still exit (may exit 0 with error in output or exit 1) + if res.ExitCode == 0 { + out := res.jsonOutput(t) + // Should NOT get a 200 — should have a proxy error + if _, ok := out.Responses["200"]; ok { + t.Error("expected proxy connection error, but got 200") + } + } +} + +func TestProxyFromConfigFile(t *testing.T) { + t.Parallel() + + config := ` +url: "http://example.com" +requests: 1 +quiet: true +output: json +dryRun: true +proxy: + - http://127.0.0.1:8080 +` + configPath := writeTemp(t, "proxy_config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "dry-run") +} + +func TestProxyFromEnv(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_DRY_RUN": "true", + "SARIN_OUTPUT": "json", + "SARIN_PROXY": "http://127.0.0.1:8080", + }, "-q") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "dry-run") +} diff --git a/e2e/request_test.go b/e2e/request_test.go new file mode 100644 index 0000000..6180641 --- /dev/null +++ b/e2e/request_test.go @@ -0,0 +1,331 @@ +package e2e + +import ( + "net/http" + "slices" + "testing" +) + +func TestMethodGET(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodGet { + t.Errorf("expected default method GET, got %s", req.Method) + } +} + +func TestMethodPOST(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodPost { + t.Errorf("expected method POST, got %s", req.Method) + } +} + +func TestMethodExplicit(t *testing.T) { + t.Parallel() + methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"} + for _, method := range methods { + t.Run(method, func(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", method, "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != method { + t.Errorf("expected method %s, got %s", method, req.Method) + } + }) + } +} + +func TestMultipleMethods(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // With multiple methods, sarin cycles through them + res := run("-U", cs.URL, "-r", "4", "-M", "GET", "-M", "POST", "-q", "-o", "json") + assertExitCode(t, res, 0) + + reqs := cs.allRequests() + if len(reqs) != 4 { + t.Fatalf("expected 4 requests, got %d", len(reqs)) + } + + // Should see both GET and POST used + methods := make(map[string]bool) + for _, r := range reqs { + methods[r.Method] = true + } + if !methods["GET"] || !methods["POST"] { + t.Errorf("expected both GET and POST to be used, got methods: %v", methods) + } +} + +func TestSingleHeader(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-H", "X-Custom: hello", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals, ok := req.Headers["X-Custom"] + if !ok { + t.Fatalf("expected X-Custom header, got headers: %v", req.Headers) + } + if len(vals) != 1 || vals[0] != "hello" { + t.Errorf("expected X-Custom: [hello], got %v", vals) + } +} + +func TestMultipleHeaders(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", + "-H", "X-First: one", + "-H", "X-Second: two", + "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-First"]; len(v) == 0 || v[0] != "one" { + t.Errorf("expected X-First: one, got %v", v) + } + if v := req.Headers["X-Second"]; len(v) == 0 || v[0] != "two" { + t.Errorf("expected X-Second: two, got %v", v) + } +} + +func TestHeaderWithEmptyValue(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Header without ": " separator should have empty value + res := run("-U", cs.URL, "-r", "1", "-H", "X-Empty", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if _, ok := req.Headers["X-Empty"]; !ok { + t.Errorf("expected X-Empty header to be present, got headers: %v", req.Headers) + } +} + +func TestDefaultUserAgentHeader(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + ua, ok := req.Headers["User-Agent"] + if !ok || len(ua) == 0 { + t.Fatalf("expected User-Agent header, got headers: %v", req.Headers) + } + assertContains(t, ua[0], "Sarin/") +} + +func TestCustomUserAgentOverridesDefault(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-H", "User-Agent: MyAgent/1.0", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + ua := req.Headers["User-Agent"] + if len(ua) == 0 { + t.Fatal("expected User-Agent header") + } + // When user sets User-Agent, the default should not be added + if slices.Contains(ua, "MyAgent/1.0") { + return // found the custom one + } + t.Errorf("expected custom User-Agent 'MyAgent/1.0', got %v", ua) +} + +func TestSingleParam(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-P", "key1=value1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals, ok := req.Query["key1"] + if !ok { + t.Fatalf("expected key1 param, got query: %v", req.Query) + } + if len(vals) != 1 || vals[0] != "value1" { + t.Errorf("expected key1=[value1], got %v", vals) + } +} + +func TestMultipleParams(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", + "-P", "a=1", + "-P", "b=2", + "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["a"]; len(v) == 0 || v[0] != "1" { + t.Errorf("expected a=1, got %v", v) + } + if v := req.Query["b"]; len(v) == 0 || v[0] != "2" { + t.Errorf("expected b=2, got %v", v) + } +} + +func TestParamsFromURL(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Params in the URL itself should be extracted and sent + res := run("-U", cs.URL+"?fromurl=yes", "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["fromurl"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected fromurl=yes from URL query, got %v", v) + } +} + +func TestParamsFromURLAndFlag(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Both URL params and -P params should be sent + res := run("-U", cs.URL+"?fromurl=yes", "-r", "1", "-P", "fromflag=also", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["fromurl"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected fromurl=yes, got %v", v) + } + if v := req.Query["fromflag"]; len(v) == 0 || v[0] != "also" { + t.Errorf("expected fromflag=also, got %v", v) + } +} + +func TestSingleCookie(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-C", "session=abc123", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Cookies["session"]; !ok || v != "abc123" { + t.Errorf("expected cookie session=abc123, got cookies: %v", req.Cookies) + } +} + +func TestMultipleCookies(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", + "-C", "session=abc", + "-C", "token=xyz", + "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Cookies["session"]; !ok || v != "abc" { + t.Errorf("expected cookie session=abc, got %v", req.Cookies) + } + if v, ok := req.Cookies["token"]; !ok || v != "xyz" { + t.Errorf("expected cookie token=xyz, got %v", req.Cookies) + } +} + +func TestBody(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-B", "hello world", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "hello world" { + t.Errorf("expected body 'hello world', got %q", req.Body) + } +} + +func TestBodyJSON(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + jsonBody := `{"name":"test","value":42}` + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-B", jsonBody, "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != jsonBody { + t.Errorf("expected body %q, got %q", jsonBody, req.Body) + } +} + +func TestURLPath(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL+"/api/v1/users", "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Path != "/api/v1/users" { + t.Errorf("expected path /api/v1/users, got %s", req.Path) + } +} + +func TestParamWithEmptyValue(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Param without = value + res := run("-U", cs.URL, "-r", "1", "-P", "empty", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if _, ok := req.Query["empty"]; !ok { + t.Errorf("expected 'empty' param to be present, got query: %v", req.Query) + } +} diff --git a/e2e/script_errors_test.go b/e2e/script_errors_test.go new file mode 100644 index 0000000..c248983 --- /dev/null +++ b/e2e/script_errors_test.go @@ -0,0 +1,137 @@ +package e2e + +import ( + "testing" +) + +func TestJsScriptModifiesPath(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) { req.path = "/js-modified"; return req; }` + scriptPath := writeTemp(t, "modify_path.js", script) + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", "@"+scriptPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Path != "/js-modified" { + t.Errorf("expected path /js-modified from JS script, got %s", req.Path) + } +} + +func TestJsScriptRuntimeError(t *testing.T) { + t.Parallel() + + // This script throws an error at runtime + script := `function transform(req) { throw new Error("runtime boom"); }` + + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", script) + assertExitCode(t, res, 0) + + // The request should fail with a script error, not a 200 + out := res.jsonOutput(t) + if _, ok := out.Responses["200"]; ok { + t.Error("expected script runtime error, but got 200") + } +} + +func TestLuaScriptRuntimeError(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Script that will error at runtime + script := `function transform(req) error("lua runtime boom") end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-lua", script) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + if _, ok := out.Responses["200"]; ok { + t.Error("expected script runtime error, but got 200") + } +} + +func TestJsScriptReturnsNull(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // transform returns null instead of object + script := `function transform(req) { return null; }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", script) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + if _, ok := out.Responses["200"]; ok { + t.Error("expected error for null return, but got 200") + } +} + +func TestJsScriptReturnsUndefined(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // transform returns nothing (undefined) + script := `function transform(req) { }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", "-js", script) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + if _, ok := out.Responses["200"]; ok { + t.Error("expected error for undefined return, but got 200") + } +} + +func TestScriptFromNonexistentFile(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json", + "-lua", "@/nonexistent/path/script.lua") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") + assertContains(t, res.Stderr, "failed to load script") +} + +func TestScriptFromNonexistentURL(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-q", "-o", "json", + "-js", "@http://127.0.0.1:1/nonexistent.js") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") + assertContains(t, res.Stderr, "failed to load script") +} + +func TestMultipleLuaAndJsScripts(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + lua1 := `function transform(req) req.headers["X-Lua-1"] = {"yes"} return req end` + lua2 := `function transform(req) req.headers["X-Lua-2"] = {"yes"} return req end` + js1 := `function transform(req) { req.headers["X-Js-1"] = ["yes"]; return req; }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", lua1, "-lua", lua2, "-js", js1) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Lua-1"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Lua-1: yes, got %v", v) + } + if v := req.Headers["X-Lua-2"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Lua-2: yes, got %v", v) + } + if v := req.Headers["X-Js-1"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Js-1: yes, got %v", v) + } +} diff --git a/e2e/script_test.go b/e2e/script_test.go new file mode 100644 index 0000000..2635ce3 --- /dev/null +++ b/e2e/script_test.go @@ -0,0 +1,392 @@ +package e2e + +import ( + "net/http" + "testing" +) + +func TestLuaScriptInline(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.headers["X-Lua"] = {"from-lua"} return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Headers["X-Lua"]; !ok || len(v) == 0 || v[0] != "from-lua" { + t.Errorf("expected X-Lua: from-lua, got headers: %v", req.Headers) + } +} + +func TestJsScriptInline(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) { req.headers["X-Js"] = ["from-js"]; return req; }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-js", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Headers["X-Js"]; !ok || len(v) == 0 || v[0] != "from-js" { + t.Errorf("expected X-Js: from-js, got headers: %v", req.Headers) + } +} + +func TestLuaScriptFromFile(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + scriptContent := `function transform(req) + req.headers["X-From-File"] = {"yes"} + return req +end` + scriptPath := writeTemp(t, "test.lua", scriptContent) + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", "@"+scriptPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Headers["X-From-File"]; !ok || len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-From-File: yes, got headers: %v", req.Headers) + } +} + +func TestJsScriptFromFile(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + scriptContent := `function transform(req) { + req.headers["X-From-File"] = ["yes"]; + return req; +}` + scriptPath := writeTemp(t, "test.js", scriptContent) + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-js", "@"+scriptPath) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Headers["X-From-File"]; !ok || len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-From-File: yes, got headers: %v", req.Headers) + } +} + +func TestLuaScriptModifiesMethod(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.method = "PUT" return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodPut { + t.Errorf("expected method PUT after Lua transform, got %s", req.Method) + } +} + +func TestJsScriptModifiesMethod(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) { req.method = "DELETE"; return req; }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-js", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodDelete { + t.Errorf("expected method DELETE after JS transform, got %s", req.Method) + } +} + +func TestLuaScriptModifiesPath(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.path = "/modified" return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Path != "/modified" { + t.Errorf("expected path /modified, got %s", req.Path) + } +} + +func TestLuaScriptModifiesBody(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.body = "lua-body" return req end` + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "lua-body" { + t.Errorf("expected body 'lua-body', got %q", req.Body) + } +} + +func TestJsScriptModifiesBody(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) { req.body = "js-body"; return req; }` + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-js", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "js-body" { + t.Errorf("expected body 'js-body', got %q", req.Body) + } +} + +func TestLuaScriptModifiesParams(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.params["lua_param"] = {"lua_value"} return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Query["lua_param"]; !ok || len(v) == 0 || v[0] != "lua_value" { + t.Errorf("expected lua_param=lua_value, got query: %v", req.Query) + } +} + +func TestJsScriptModifiesParams(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) { req.params["js_param"] = ["js_value"]; return req; }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-js", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Query["js_param"]; !ok || len(v) == 0 || v[0] != "js_value" { + t.Errorf("expected js_param=js_value, got query: %v", req.Query) + } +} + +func TestLuaScriptModifiesCookies(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.cookies["lua_cookie"] = {"lua_val"} return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Cookies["lua_cookie"]; !ok || v != "lua_val" { + t.Errorf("expected cookie lua_cookie=lua_val, got cookies: %v", req.Cookies) + } +} + +func TestJsScriptModifiesCookies(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) { req.cookies["js_cookie"] = ["js_val"]; return req; }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-js", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Cookies["js_cookie"]; !ok || v != "js_val" { + t.Errorf("expected cookie js_cookie=js_val, got cookies: %v", req.Cookies) + } +} + +func TestScriptChainLuaThenJs(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + luaScript := `function transform(req) req.headers["X-Step"] = {"lua"} return req end` + jsScript := `function transform(req) { req.headers["X-Js-Step"] = ["js"]; return req; }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", luaScript, + "-js", jsScript) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v, ok := req.Headers["X-Step"]; !ok || len(v) == 0 || v[0] != "lua" { + t.Errorf("expected X-Step: lua from Lua script, got %v", req.Headers["X-Step"]) + } + if v, ok := req.Headers["X-Js-Step"]; !ok || len(v) == 0 || v[0] != "js" { + t.Errorf("expected X-Js-Step: js from JS script, got %v", req.Headers["X-Js-Step"]) + } +} + +func TestMultipleLuaScriptsChained(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + lua1 := `function transform(req) req.headers["X-First"] = {"1"} return req end` + lua2 := `function transform(req) req.headers["X-Second"] = {"2"} return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", lua1, + "-lua", lua2) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-First"]; len(v) == 0 || v[0] != "1" { + t.Errorf("expected X-First: 1, got %v", v) + } + if v := req.Headers["X-Second"]; len(v) == 0 || v[0] != "2" { + t.Errorf("expected X-Second: 2, got %v", v) + } +} + +func TestScriptWithEscapedAt(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // @@ means the first @ is stripped, rest is treated as inline script + script := `@@function transform(req) req.headers["X-At"] = {"escaped"} return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", script) + // The @@ prefix strips one @, leaving "@function transform..." which is valid Lua? + // Actually no — after stripping the first @, it becomes: + // "@function transform(req) ..." which would be interpreted as a file reference. + // Wait — the code says: strings starting with "@@" → content = source[1:] = "@function..." + // Then it's returned as inline content "@function transform..." + // Lua would fail because "@" is not valid Lua syntax. + // So this test just validates that the @@ mechanism doesn't crash. + // It should fail at the validation step since "@function..." is not valid Lua. + assertExitCode(t, res, 1) +} + +func TestLuaScriptMultipleHeaderValues(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + script := `function transform(req) req.headers["X-Multi"] = {"val1", "val2"} return req end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals, ok := req.Headers["X-Multi"] + if !ok { + t.Fatalf("expected X-Multi header, got headers: %v", req.Headers) + } + if len(vals) != 2 || vals[0] != "val1" || vals[1] != "val2" { + t.Errorf("expected X-Multi: [val1, val2], got %v", vals) + } +} + +func TestJsScriptCanReadExistingHeaders(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Set a header via CLI, then read it in JS and set a new one based on it + script := `function transform(req) { + var original = req.headers["X-Original"]; + if (original && original.length > 0) { + req.headers["X-Copy"] = [original[0]]; + } + return req; + }` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-H", "X-Original: hello", + "-js", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Copy"]; len(v) == 0 || v[0] != "hello" { + t.Errorf("expected X-Copy: hello (copied from X-Original), got %v", v) + } +} + +func TestLuaScriptCanReadExistingParams(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Set a param via CLI, then read it in Lua + script := `function transform(req) + local original = req.params["key1"] + if original and #original > 0 then + req.params["key1_copy"] = {original[1]} + end + return req + end` + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-P", "key1=val1", + "-lua", script) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["key1_copy"]; len(v) == 0 || v[0] != "val1" { + t.Errorf("expected key1_copy=val1 (copied from key1), got %v", v) + } +} + +func TestScriptFromHTTPURL(t *testing.T) { + t.Parallel() + + // Serve a Lua script via HTTP + scriptContent := `function transform(req) req.headers["X-Remote"] = {"yes"} return req end` + scriptServer := statusServerWithBody(scriptContent) + defer scriptServer.Close() + + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-lua", "@"+scriptServer.URL) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Remote"]; len(v) == 0 || v[0] != "yes" { + t.Errorf("expected X-Remote: yes from remote script, got %v", req.Headers) + } +} diff --git a/e2e/show_config_extra_test.go b/e2e/show_config_extra_test.go new file mode 100644 index 0000000..ea8b584 --- /dev/null +++ b/e2e/show_config_extra_test.go @@ -0,0 +1,36 @@ +package e2e + +import ( + "testing" +) + +func TestShowConfigFromYAML(t *testing.T) { + t.Parallel() + config := ` +url: "http://example.com" +requests: 1 +showConfig: true +` + configPath := writeTemp(t, "show_config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + // Non-TTY: should output raw YAML config + assertContains(t, res.Stdout, "url:") + assertContains(t, res.Stdout, "example.com") +} + +func TestShowConfigFromEnv(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "1", + "SARIN_SHOW_CONFIG": "true", + }, "-q") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "url:") + assertContains(t, res.Stdout, "example.com") +} diff --git a/e2e/show_config_test.go b/e2e/show_config_test.go new file mode 100644 index 0000000..06868fc --- /dev/null +++ b/e2e/show_config_test.go @@ -0,0 +1,61 @@ +package e2e + +import ( + "testing" +) + +func TestShowConfigNonTTY(t *testing.T) { + t.Parallel() + + // In non-TTY mode (like tests), -s should output raw YAML and exit + res := run("-U", "http://example.com", "-r", "1", "-s") + assertExitCode(t, res, 0) + + // Should contain YAML-formatted config + assertContains(t, res.Stdout, "url:") + assertContains(t, res.Stdout, "example.com") + assertContains(t, res.Stdout, "requests:") +} + +func TestShowConfigContainsMethod(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-M", "POST", "-s") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "method:") + assertContains(t, res.Stdout, "POST") +} + +func TestShowConfigContainsHeaders(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-s", + "-H", "X-Custom: test-value") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "X-Custom") + assertContains(t, res.Stdout, "test-value") +} + +func TestShowConfigContainsTimeout(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-T", "5s", "-s") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "timeout:") +} + +func TestShowConfigWithEnvVars(t *testing.T) { + t.Parallel() + + res := runWithEnv(map[string]string{ + "SARIN_URL": "http://example.com", + "SARIN_REQUESTS": "5", + }, "-s") + assertExitCode(t, res, 0) + + assertContains(t, res.Stdout, "example.com") + assertContains(t, res.Stdout, "requests:") +} diff --git a/e2e/signal_test.go b/e2e/signal_test.go new file mode 100644 index 0000000..4af69d5 --- /dev/null +++ b/e2e/signal_test.go @@ -0,0 +1,116 @@ +package e2e + +import ( + "encoding/json" + "syscall" + "testing" + "time" +) + +func TestSIGINTGracefulShutdown(t *testing.T) { + t.Parallel() + srv := slowServer(100 * time.Millisecond) + defer srv.Close() + + // Start a duration-based test that would run for a long time + cmd, stdout := startProcess( + "-U", srv.URL, "-d", "30s", "-q", "-o", "json", + ) + + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start process: %v", err) + } + + // Let it run for a bit so some requests complete + time.Sleep(500 * time.Millisecond) + + // Send SIGINT for graceful shutdown + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + t.Fatalf("failed to send SIGINT: %v", err) + } + + // Wait for process to exit + err := cmd.Wait() + _ = err // May exit with 0 or non-zero depending on timing + + // Should have produced valid JSON output with partial results + output := stdout.String() + if output == "" { + t.Fatal("expected JSON output after SIGINT, got empty stdout") + } + + var out outputData + if err := json.Unmarshal([]byte(output), &out); err != nil { + t.Fatalf("expected valid JSON after graceful shutdown: %v\nstdout: %s", err, output) + } + + count, _ := out.Total.Count.Int64() + if count < 1 { + t.Errorf("expected at least 1 request before shutdown, got %d", count) + } +} + +func TestSIGTERMGracefulShutdown(t *testing.T) { + t.Parallel() + srv := slowServer(100 * time.Millisecond) + defer srv.Close() + + cmd, stdout := startProcess( + "-U", srv.URL, "-d", "30s", "-q", "-o", "json", + ) + + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start process: %v", err) + } + + time.Sleep(500 * time.Millisecond) + + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("failed to send SIGTERM: %v", err) + } + + err := cmd.Wait() + _ = err + + output := stdout.String() + if output == "" { + t.Fatal("expected JSON output after SIGTERM, got empty stdout") + } + + var out outputData + if err := json.Unmarshal([]byte(output), &out); err != nil { + t.Fatalf("expected valid JSON after graceful shutdown: %v\nstdout: %s", err, output) + } +} + +func TestSIGINTExitsInReasonableTime(t *testing.T) { + t.Parallel() + srv := slowServer(50 * time.Millisecond) + defer srv.Close() + + cmd, _ := startProcess( + "-U", srv.URL, "-d", "60s", "-q", "-o", "none", + ) + + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start process: %v", err) + } + + time.Sleep(300 * time.Millisecond) + + if err := cmd.Process.Signal(syscall.SIGINT); err != nil { + t.Fatalf("failed to send SIGINT: %v", err) + } + + // Should exit within 5 seconds + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + select { + case <-done: + // Good — exited in time + case <-time.After(5 * time.Second): + cmd.Process.Kill() + t.Fatal("process did not exit within 5 seconds after SIGINT") + } +} diff --git a/e2e/template_funcs_extra_test.go b/e2e/template_funcs_extra_test.go new file mode 100644 index 0000000..5f345e3 --- /dev/null +++ b/e2e/template_funcs_extra_test.go @@ -0,0 +1,116 @@ +package e2e + +import ( + "strings" + "testing" +) + +func TestDictStr(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // dict_Str creates a map; use with index to retrieve a value + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ $d := dict_Str "name" "alice" "role" "admin" }}{{ index $d "name" }}-{{ index $d "role" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "alice-admin" { + t.Errorf("expected body alice-admin, got %q", req.Body) + } +} + +func TestStringsToDate(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // strings_ToDate parses a date string; verify it produces a non-empty result + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-H", `X-Date: {{ strings_ToDate "2024-06-15" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Date"]; len(v) == 0 || v[0] == "" { + t.Error("expected X-Date to have a non-empty value") + } else { + assertContains(t, v[0], "2024") + } +} + +func TestFileBase64NonexistentFile(t *testing.T) { + t.Parallel() + + // file_Base64 errors at runtime, the error becomes the response key + res := run("-U", "http://example.com", "-r", "1", "-z", "-q", "-o", "json", + "-B", `{{ file_Base64 "/nonexistent/file.txt" }}`) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + // Should have a template rendering error as response key, not "dry-run" + if _, ok := out.Responses["dry-run"]; ok { + t.Error("expected template error, but got dry-run response") + } + assertResponseCount(t, out, 1) +} + +func TestFileBase64FailedHTTP(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-z", "-q", "-o", "json", + "-B", `{{ file_Base64 "http://127.0.0.1:1/nonexistent" }}`) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + if _, ok := out.Responses["dry-run"]; ok { + t.Error("expected template error, but got dry-run response") + } + assertResponseCount(t, out, 1) +} + +func TestMultipleValuesFlags(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-V", "KEY1=val1", "-V", "KEY2=val2", + "-H", "X-K1: {{ .Values.KEY1 }}", + "-H", "X-K2: {{ .Values.KEY2 }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-K1"]; len(v) == 0 || v[0] != "val1" { + t.Errorf("expected X-K1: val1, got %v", v) + } + if v := req.Headers["X-K2"]; len(v) == 0 || v[0] != "val2" { + t.Errorf("expected X-K2: val2, got %v", v) + } +} + +func TestValuesUsedInBodyAndHeader(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Same value used in both header and body within the same request + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-V", "ID={{ fakeit_UUID }}", + "-H", "X-Request-Id: {{ .Values.ID }}", + "-B", `{"id":"{{ .Values.ID }}"}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + headerID := "" + if v := req.Headers["X-Request-Id"]; len(v) > 0 { + headerID = v[0] + } + if headerID == "" { + t.Fatal("expected X-Request-Id to have a value") + } + // Body should contain the same UUID as the header + if !strings.Contains(req.Body, headerID) { + t.Errorf("expected body to contain same ID as header (%s), got body: %s", headerID, req.Body) + } +} diff --git a/e2e/template_funcs_test.go b/e2e/template_funcs_test.go new file mode 100644 index 0000000..3058e94 --- /dev/null +++ b/e2e/template_funcs_test.go @@ -0,0 +1,170 @@ +package e2e + +import ( + "testing" +) + +func TestStringToUpper(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-H", `X-Upper: {{ strings_ToUpper "hello" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Upper"]; len(v) == 0 || v[0] != "HELLO" { + t.Errorf("expected X-Upper: HELLO, got %v", v) + } +} + +func TestStringToLower(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-H", `X-Lower: {{ strings_ToLower "WORLD" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Lower"]; len(v) == 0 || v[0] != "world" { + t.Errorf("expected X-Lower: world, got %v", v) + } +} + +func TestStringReplace(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ strings_Replace "foo-bar-baz" "-" "_" -1 }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "foo_bar_baz" { + t.Errorf("expected body foo_bar_baz, got %q", req.Body) + } +} + +func TestStringRemoveSpaces(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ strings_RemoveSpaces "hello world foo" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "helloworldfoo" { + t.Errorf("expected body helloworldfoo, got %q", req.Body) + } +} + +func TestStringTrimPrefix(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ strings_TrimPrefix "hello-world" "hello-" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "world" { + t.Errorf("expected body world, got %q", req.Body) + } +} + +func TestStringTrimSuffix(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ strings_TrimSuffix "hello-world" "-world" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "hello" { + t.Errorf("expected body hello, got %q", req.Body) + } +} + +func TestSliceJoin(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ slice_Join (slice_Str "a" "b" "c") ", " }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "a, b, c" { + t.Errorf("expected body 'a, b, c', got %q", req.Body) + } +} + +func TestStringFirst(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ strings_First "abcdef" 3 }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "abc" { + t.Errorf("expected body abc, got %q", req.Body) + } +} + +func TestStringLast(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ strings_Last "abcdef" 3 }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "def" { + t.Errorf("expected body def, got %q", req.Body) + } +} + +func TestStringTruncate(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ strings_Truncate "hello world" 5 }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "hello..." { + t.Errorf("expected body 'hello...', got %q", req.Body) + } +} + +func TestSliceStr(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{{ slice_Join (slice_Str "a" "b" "c") "-" }}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != "a-b-c" { + t.Errorf("expected body a-b-c, got %q", req.Body) + } +} diff --git a/e2e/template_test.go b/e2e/template_test.go new file mode 100644 index 0000000..a2f6575 --- /dev/null +++ b/e2e/template_test.go @@ -0,0 +1,241 @@ +package e2e + +import ( + "testing" +) + +func TestTemplateInHeader(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Use a template function that generates a UUID + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-H", "X-Request-Id: {{ fakeit_UUID }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals, ok := req.Headers["X-Request-Id"] + if !ok || len(vals) == 0 { + t.Fatalf("expected X-Request-Id header, got headers: %v", req.Headers) + } + // UUID format: 8-4-4-4-12 + if len(vals[0]) != 36 { + t.Errorf("expected UUID (36 chars), got %q (%d chars)", vals[0], len(vals[0])) + } +} + +func TestTemplateInParam(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-P", "id={{ fakeit_UUID }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals, ok := req.Query["id"] + if !ok || len(vals) == 0 { + t.Fatalf("expected 'id' param, got query: %v", req.Query) + } + if len(vals[0]) != 36 { + t.Errorf("expected UUID in param value, got %q", vals[0]) + } +} + +func TestTemplateInBody(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-B", `{"id":"{{ fakeit_UUID }}"}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if len(req.Body) < 36 { + t.Errorf("expected body to contain a UUID, got %q", req.Body) + } + assertContains(t, req.Body, `"id":"`) +} + +func TestTemplateInURLPath(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL+"/api/{{ fakeit_UUID }}", "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if len(req.Path) < 5+36 { // "/api/" + UUID + t.Errorf("expected path to contain a UUID, got %q", req.Path) + } +} + +func TestValuesBasic(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-V", "MY_VAR=hello", + "-H", "X-Val: {{ .Values.MY_VAR }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Val"]; len(v) == 0 || v[0] != "hello" { + t.Errorf("expected X-Val: hello from Values, got %v", v) + } +} + +func TestValuesMultiple(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-V", "A=first", + "-V", "B=second", + "-H", "X-A: {{ .Values.A }}", + "-H", "X-B: {{ .Values.B }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-A"]; len(v) == 0 || v[0] != "first" { + t.Errorf("expected X-A: first, got %v", v) + } + if v := req.Headers["X-B"]; len(v) == 0 || v[0] != "second" { + t.Errorf("expected X-B: second, got %v", v) + } +} + +func TestValuesWithTemplate(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + // Values themselves can contain templates + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-V", "REQ_ID={{ fakeit_UUID }}", + "-H", "X-Request-Id: {{ .Values.REQ_ID }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + vals, ok := req.Headers["X-Request-Id"] + if !ok || len(vals) == 0 { + t.Fatalf("expected X-Request-Id header, got %v", req.Headers) + } + if len(vals[0]) != 36 { + t.Errorf("expected UUID from value template, got %q", vals[0]) + } +} + +func TestValuesInParam(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-V", "TOKEN=abc123", + "-P", "token={{ .Values.TOKEN }}") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Query["token"]; len(v) == 0 || v[0] != "abc123" { + t.Errorf("expected token=abc123, got %v", v) + } +} + +func TestValuesInBody(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-M", "POST", "-q", "-o", "json", + "-V", "NAME=test-user", + "-B", `{"name":"{{ .Values.NAME }}"}`) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Body != `{"name":"test-user"}` { + t.Errorf("expected body with interpolated value, got %q", req.Body) + } +} + +func TestValuesInURLPath(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL+"/users/{{ .Values.USER_ID }}", "-r", "1", "-q", "-o", "json", + "-V", "USER_ID=42") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Path != "/users/42" { + t.Errorf("expected path /users/42, got %s", req.Path) + } +} + +func TestTemplateGeneratesDifferentValues(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "5", "-c", "1", "-q", "-o", "json", + "-H", "X-Unique: {{ fakeit_UUID }}") + assertExitCode(t, res, 0) + + reqs := cs.allRequests() + if len(reqs) < 5 { + t.Fatalf("expected 5 requests, got %d", len(reqs)) + } + + // UUIDs should be unique across requests + seen := make(map[string]bool) + for _, r := range reqs { + vals := r.Headers["X-Unique"] + if len(vals) > 0 { + seen[vals[0]] = true + } + } + if len(seen) < 2 { + t.Errorf("expected template to generate different UUIDs across requests, got %d unique values", len(seen)) + } +} + +func TestTemplateFunctionFakeit(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + t.Cleanup(cs.Close) + + // Test various fakeit functions + tests := []struct { + name string + template string + }{ + {"UUID", "{{ fakeit_UUID }}"}, + {"Name", "{{ fakeit_Name }}"}, + {"Email", "{{ fakeit_Email }}"}, + {"Number", "{{ fakeit_Number 1 100 }}"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + cs := newCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL, "-r", "1", "-q", "-o", "json", + "-H", "X-Test: "+tt.template) + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if v := req.Headers["X-Test"]; len(v) == 0 || v[0] == "" { + t.Errorf("expected non-empty value from %s, got %v", tt.template, v) + } + }) + } +} diff --git a/e2e/timeout_test.go b/e2e/timeout_test.go new file mode 100644 index 0000000..dd8200a --- /dev/null +++ b/e2e/timeout_test.go @@ -0,0 +1,110 @@ +package e2e + +import ( + "testing" + "time" +) + +func TestRequestTimeout(t *testing.T) { + t.Parallel() + + // Server that takes 2 seconds to respond + srv := slowServer(2 * time.Second) + defer srv.Close() + + // Timeout of 200ms — should fail with timeout error + res := run("-U", srv.URL, "-r", "1", "-T", "200ms", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + // Should NOT have "200" — should have a timeout error + if _, ok := out.Responses["200"]; ok { + t.Error("expected timeout error, but got 200") + } + // Total count should still be 1 (the timed-out request is counted) + assertResponseCount(t, out, 1) +} + +func TestRequestTimeoutMultiple(t *testing.T) { + t.Parallel() + + srv := slowServer(2 * time.Second) + defer srv.Close() + + res := run("-U", srv.URL, "-r", "3", "-c", "3", "-T", "200ms", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertResponseCount(t, out, 3) + + // None should be 200 + if _, ok := out.Responses["200"]; ok { + t.Error("expected all requests to timeout, but got some 200s") + } +} + +func TestTimeoutDoesNotAffectFastRequests(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + // Short timeout but server responds instantly — should succeed + res := run("-U", srv.URL, "-r", "3", "-T", "5s", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") + assertResponseCount(t, out, 3) +} + +func TestDurationStopsAfterTime(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + start := time.Now() + res := run("-U", srv.URL, "-d", "1s", "-q", "-o", "json") + elapsed := time.Since(start) + + assertExitCode(t, res, 0) + + // Should finish roughly around 1s (allow some tolerance) + if elapsed < 900*time.Millisecond { + t.Errorf("expected test to run ~1s, but finished in %v", elapsed) + } + if elapsed > 3*time.Second { + t.Errorf("expected test to finish around 1s, but took %v", elapsed) + } +} + +func TestDurationWithRequestLimit(t *testing.T) { + t.Parallel() + srv := echoServer() + defer srv.Close() + + // Request limit reached before duration — should stop early + res := run("-U", srv.URL, "-r", "2", "-d", "30s", "-q", "-o", "json") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertResponseCount(t, out, 2) +} + +func TestDurationWithSlowServerStopsAtDuration(t *testing.T) { + t.Parallel() + + // Server delays 500ms per request + srv := slowServer(500 * time.Millisecond) + defer srv.Close() + + start := time.Now() + res := run("-U", srv.URL, "-d", "1s", "-c", "1", "-q", "-o", "json") + elapsed := time.Since(start) + + assertExitCode(t, res, 0) + + // Should stop after ~1s even though requests are slow + if elapsed > 3*time.Second { + t.Errorf("expected to stop around 1s duration, took %v", elapsed) + } +} diff --git a/e2e/tls_test.go b/e2e/tls_test.go new file mode 100644 index 0000000..b692b4a --- /dev/null +++ b/e2e/tls_test.go @@ -0,0 +1,164 @@ +package e2e + +import ( + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHTTPSWithInsecureFlag(t *testing.T) { + t.Parallel() + + // Create a TLS server with a self-signed cert + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Without --insecure, it should fail (cert not trusted) + // With --insecure, it should succeed + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json", "-I") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") +} + +func TestHTTPSWithoutInsecureFails(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Without --insecure, should get a TLS error (not a clean 200) + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json") + assertExitCode(t, res, 0) // Process still exits 0, but response key is an error + + out := res.jsonOutput(t) + // Should NOT have a "200" key — should have a TLS error + if _, ok := out.Responses["200"]; ok { + t.Error("expected TLS error without --insecure, but got 200") + } +} + +func TestHTTPSInsecureViaCLILongFlag(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // Use the long form flag + res := run("-U", srv.URL, "-r", "1", "-q", "-o", "json", "-insecure") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") +} + +func TestHTTPSInsecureViaConfigFile(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + config := ` +url: "` + srv.URL + `" +requests: 1 +insecure: true +quiet: true +output: json +` + configPath := writeTemp(t, "tls_config.yaml", config) + + res := run("-f", configPath) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") +} + +func TestHTTPSInsecureViaEnv(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + res := runWithEnv(map[string]string{ + "SARIN_URL": srv.URL, + "SARIN_REQUESTS": "1", + "SARIN_INSECURE": "true", + "SARIN_QUIET": "true", + "SARIN_OUTPUT": "json", + }) + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") +} + +func TestHTTPSEchoServer(t *testing.T) { + t.Parallel() + + // TLS echo server that returns request details + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "method": r.Method, + "path": r.URL.Path, + "tls": r.TLS != nil, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + // Verify request was received over TLS + res := run("-U", srv.URL+"/secure-path", "-r", "1", "-q", "-o", "json", "-I") + assertExitCode(t, res, 0) + + out := res.jsonOutput(t) + assertHasResponseKey(t, out, "200") +} + +// tlsCaptureServer is like captureServer but with TLS +func tlsCaptureServer() *captureServer { + cs := &captureServer{} + cs.Server = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cs.mu.Lock() + cs.requests = append(cs.requests, echoResponse{ + Method: r.Method, + Path: r.URL.Path, + }) + cs.mu.Unlock() + w.WriteHeader(http.StatusOK) + })) + cs.TLS = &tls.Config{} + cs.StartTLS() + return cs +} + +func TestHTTPSHeadersSentCorrectly(t *testing.T) { + t.Parallel() + cs := tlsCaptureServer() + defer cs.Close() + + res := run("-U", cs.URL+"/api/test", "-r", "1", "-M", "POST", "-q", "-o", "json", "-I") + assertExitCode(t, res, 0) + + req := cs.lastRequest() + if req.Method != http.MethodPost { + t.Errorf("expected POST over HTTPS, got %s", req.Method) + } + if req.Path != "/api/test" { + t.Errorf("expected path /api/test over HTTPS, got %s", req.Path) + } +} diff --git a/e2e/validation_extra_test.go b/e2e/validation_extra_test.go new file mode 100644 index 0000000..ab4194e --- /dev/null +++ b/e2e/validation_extra_test.go @@ -0,0 +1,13 @@ +package e2e + +import ( + "testing" +) + +func TestValidation_ConcurrencyExceedsMax(t *testing.T) { + t.Parallel() + + res := run("-U", "http://example.com", "-r", "1", "-q", "-c", "200000000") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "concurrency must not exceed 100,000,000") +} diff --git a/e2e/validation_test.go b/e2e/validation_test.go new file mode 100644 index 0000000..8fc40f4 --- /dev/null +++ b/e2e/validation_test.go @@ -0,0 +1,168 @@ +package e2e + +import ( + "testing" +) + +func TestValidation_MissingURL(t *testing.T) { + t.Parallel() + res := run("-r", "1") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "URL") + assertContains(t, res.Stderr, "required") +} + +func TestValidation_InvalidURLScheme(t *testing.T) { + t.Parallel() + res := run("-U", "ftp://example.com", "-r", "1") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "URL") + assertContains(t, res.Stderr, "scheme") +} + +func TestValidation_URLWithoutHost(t *testing.T) { + t.Parallel() + res := run("-U", "http://", "-r", "1") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "URL") +} + +func TestValidation_NoRequestsOrDuration(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "request count or duration") +} + +func TestValidation_ZeroRequests(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "0") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "Requests") +} + +func TestValidation_ZeroDuration(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-d", "0s") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "Duration") +} + +func TestValidation_ZeroRequestsAndZeroDuration(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "0", "-d", "0s") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestValidation_ConcurrencyZero(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", "-c", "0") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "concurrency") +} + +func TestValidation_TimeoutZero(t *testing.T) { + t.Parallel() + // Timeout of 0 is invalid (must be > 0) + res := run("-U", "http://example.com", "-r", "1", "-T", "0s") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "timeout") +} + +func TestValidation_InvalidOutputFormat(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", "-o", "xml") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "Output") +} + +func TestValidation_InvalidProxyScheme(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", "-X", "ftp://proxy.example.com:8080") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "proxy") +} + +func TestValidation_EmptyLuaScript(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", "-lua", "") + assertExitCode(t, res, 1) +} + +func TestValidation_EmptyJsScript(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", "-js", "") + assertExitCode(t, res, 1) +} + +func TestValidation_LuaScriptMissingTransform(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", + "-lua", `print("hello")`) + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestValidation_JsScriptMissingTransform(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", + "-js", `console.log("hello")`) + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestValidation_LuaScriptSyntaxError(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", + "-lua", `function transform(req invalid syntax`) + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestValidation_JsScriptSyntaxError(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", + "-js", `function transform(req { invalid`) + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestValidation_ScriptEmptyFileRef(t *testing.T) { + t.Parallel() + // "@" with nothing after it + res := run("-U", "http://example.com", "-r", "1", "-lua", "@") + assertExitCode(t, res, 1) +} + +func TestValidation_ScriptNonexistentFile(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", + "-lua", "@/nonexistent/path/script.lua") + assertExitCode(t, res, 1) +} + +func TestValidation_InvalidTemplateInHeader(t *testing.T) { + t.Parallel() + res := run("-U", "http://example.com", "-r", "1", + "-H", "X-Test: {{ invalid_func }}") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestValidation_InvalidTemplateInBody(t *testing.T) { + t.Parallel() + // Use a template with invalid syntax (unclosed action) + res := run("-U", "http://example.com", "-r", "1", + "-B", "{{ invalid_func_xyz }}") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "VALIDATION") +} + +func TestValidation_MultipleErrors(t *testing.T) { + t.Parallel() + // No URL, no requests/duration — should report multiple validation errors + res := run("-c", "1") + assertExitCode(t, res, 1) + assertContains(t, res.Stderr, "URL") +}