5 Commits

Author SHA1 Message Date
7c246102ff add CI workflow and coverage-gap validation tests 2026-02-26 19:13:42 +04:00
4b3230bb27 Add e2e tests 2026-02-18 00:03:59 +04:00
d197e90103 Merge pull request #172 from aykhans/refactor/minor-improvements
Rename Append to Merge, replace strings_Join with slice_Join, and auto-detect non-TTY output
2026-02-15 16:55:40 +04:00
ae054bb3d6 update docs 2026-02-15 16:52:52 +04:00
61af28a3d3 Override Methods and Bodies instead of appending in Config.Merge 2026-02-15 16:27:36 +04:00
32 changed files with 4669 additions and 35 deletions

24
.github/workflows/e2e.yaml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: e2e-tests
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
e2e:
name: e2e
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: 1.26.0
cache: true
- name: run e2e tests
run: go test ./e2e/... -v -count=1

View File

@@ -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.

65
benchmark.sh Executable file
View File

@@ -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 "============================================"

View File

@@ -105,6 +105,12 @@ SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/co
If all four files define `url`, the value from `config3.yaml` wins.
**Merge behavior by field:**
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority
- **Method and Body** — higher priority overrides lower priority (no merging)
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files
## URL
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.

View File

@@ -199,7 +199,7 @@ params:
```sh
sarin -U http://example.com/users -r 1000 -c 10 \
-P "id={{ fakeit_IntRange 1 1000 }}" \
-P "id={{ fakeit_Number 1 1000 }}" \
-P "fields=name,email"
```
@@ -211,7 +211,7 @@ url: http://example.com/users
requests: 1000
concurrency: 10
params:
id: "{{ fakeit_IntRange 1 1000 }}"
id: "{{ fakeit_Number 1 1000 }}"
fields: "name,email"
```

View File

@@ -240,7 +240,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Address
| Function | Description | Example Output |
| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- |
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
| `fakeit_City` | City name | `"Marcelside"` |
| `fakeit_Country` | Country name | `"United States of America"` |
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
@@ -256,7 +256,7 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
| `fakeit_Latitude` | Random latitude | `-73.534056` |
| `fakeit_Longitude` | Random longitude | `-147.068112` |
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}``22.921026` |
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}``-8.170450` |
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}``122.471830` |
### Game
@@ -344,8 +344,8 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
### Text
| Function | Description | Example |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` |
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |

220
e2e/basic_test.go Normal file
View File

@@ -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)
}

401
e2e/config_file_test.go Normal file
View File

@@ -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)
}
}

282
e2e/config_merge_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)
}
}

117
e2e/coverage_gaps_test.go Normal file
View File

@@ -0,0 +1,117 @@
package e2e
import "testing"
func TestValidation_InvalidTemplateInMethod(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-M", "{{ invalid_func }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Method[0]")
}
func TestValidation_InvalidTemplateInParamKey(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-P", "{{ invalid_func }}=value")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Param[0].Key")
}
func TestValidation_InvalidTemplateInCookieValue(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-C", "session={{ invalid_func }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Cookie[0].Value[0]")
}
func TestValidation_InvalidTemplateInURLPath(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com/{{ invalid_func }}", "-r", "1")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "URL.Path")
}
func TestValidation_InvalidTemplateInValues(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-V", "A={{ invalid_func }}")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Values[0]")
}
func TestValidation_ScriptURLWithoutHost(t *testing.T) {
t.Parallel()
res := run("-U", "http://example.com", "-r", "1", "-lua", "@http://")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "host")
}
func TestEnvInvalidURL(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "://bad-url",
"SARIN_REQUESTS": "1",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "SARIN_URL")
}
func TestEnvInvalidProxy(t *testing.T) {
t.Parallel()
res := runWithEnv(map[string]string{
"SARIN_URL": "http://example.com",
"SARIN_REQUESTS": "1",
"SARIN_PROXY": "://bad-proxy",
}, "-q")
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "SARIN_PROXY")
}
func TestConfigFileInvalidURLParse(t *testing.T) {
t.Parallel()
configPath := writeTemp(t, "invalid_url.yaml", `
url: "://bad-url"
requests: 1
`)
res := run("-f", configPath)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Field 'url'")
}
func TestConfigFileInvalidProxyParse(t *testing.T) {
t.Parallel()
configPath := writeTemp(t, "invalid_proxy.yaml", `
url: "http://example.com"
requests: 1
proxy: "://bad-proxy"
`)
res := run("-f", configPath)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "proxy[0]")
}
func TestConfigFileInvalidHeadersType(t *testing.T) {
t.Parallel()
configPath := writeTemp(t, "invalid_headers_type.yaml", `
url: "http://example.com"
requests: 1
headers:
- X-Test: value
- 42
`)
res := run("-f", configPath)
assertExitCode(t, res, 1)
assertContains(t, res.Stderr, "Failed to parse config file")
}

316
e2e/e2e_test.go Normal file
View File

@@ -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
}

87
e2e/env_errors_test.go Normal file
View File

@@ -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")
}

348
e2e/env_test.go Normal file
View File

@@ -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)
}
}

149
e2e/formdata_test.go Normal file
View File

@@ -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)
}

226
e2e/multi_value_test.go Normal file
View File

@@ -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"])
}
}

198
e2e/output_test.go Normal file
View File

@@ -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:")
}

103
e2e/proxy_test.go Normal file
View File

@@ -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")
}

331
e2e/request_test.go Normal file
View File

@@ -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)
}
}

137
e2e/script_errors_test.go Normal file
View File

@@ -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)
}
}

392
e2e/script_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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")
}

61
e2e/show_config_test.go Normal file
View File

@@ -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:")
}

116
e2e/signal_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -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)
}
}

170
e2e/template_funcs_test.go Normal file
View File

@@ -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)
}
}

241
e2e/template_test.go Normal file
View File

@@ -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)
}
})
}
}

110
e2e/timeout_test.go Normal file
View File

@@ -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)
}
}

164
e2e/tls_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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")
}

168
e2e/validation_test.go Normal file
View File

@@ -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")
}

View File

@@ -275,7 +275,7 @@ func (config Config) Print() bool {
func (config *Config) Merge(newConfig *Config) {
config.Files = append(config.Files, newConfig.Files...)
if len(newConfig.Methods) > 0 {
config.Methods = append(config.Methods, newConfig.Methods...)
config.Methods = newConfig.Methods
}
if newConfig.URL != nil {
config.URL = newConfig.URL
@@ -317,7 +317,7 @@ func (config *Config) Merge(newConfig *Config) {
config.Cookies = append(config.Cookies, newConfig.Cookies...)
}
if len(newConfig.Bodies) != 0 {
config.Bodies = append(config.Bodies, newConfig.Bodies...)
config.Bodies = newConfig.Bodies
}
if len(newConfig.Proxies) != 0 {
config.Proxies.Append(newConfig.Proxies...)