mirror of
https://github.com/aykhans/sarin.git
synced 2026-04-15 12:29:36 +00:00
Compare commits
2 Commits
006029aad1
...
feat/e2e-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c246102ff | |||
| 4b3230bb27 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +1,2 @@
|
|||||||
buy_me_a_coffee: aykhan
|
buy_me_a_coffee: aykhan
|
||||||
|
custom: https://commerce.coinbase.com/checkout/0f33d2fb-54a6-44f5-8783-006ebf70d1a0
|
||||||
|
|||||||
24
.github/workflows/e2e.yaml
vendored
Normal file
24
.github/workflows/e2e.yaml
vendored
Normal 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
|
||||||
4
.github/workflows/lint.yaml
vendored
4
.github/workflows/lint.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.26.1
|
go-version: 1.26.0
|
||||||
- name: go fix
|
- name: go fix
|
||||||
run: |
|
run: |
|
||||||
go fix ./...
|
go fix ./...
|
||||||
@@ -24,4 +24,4 @@ jobs:
|
|||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.11.4
|
version: v2.9.0
|
||||||
|
|||||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -35,7 +35,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
echo "VERSION=$(git describe --tags --always)" >> $GITHUB_ENV
|
||||||
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
echo "GIT_COMMIT=$(git rev-parse HEAD)" >> $GITHUB_ENV
|
||||||
echo "GO_VERSION=1.26.1" >> $GITHUB_ENV
|
echo "GO_VERSION=1.26.0" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
if: github.event_name == 'release' || inputs.build_binaries
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ARG GO_VERSION=1.26.1
|
ARG GO_VERSION=1.26.0
|
||||||
|
|
||||||
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
FROM docker.io/library/golang:${GO_VERSION}-alpine AS builder
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: "3"
|
|||||||
|
|
||||||
vars:
|
vars:
|
||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GOLANGCI_LINT_VERSION: v2.11.4
|
GOLANGCI_LINT_VERSION: v2.9.0
|
||||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
@@ -39,10 +39,10 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- "{{.GOLANGCI}} run"
|
- "{{.GOLANGCI}} run"
|
||||||
|
|
||||||
test:
|
e2e:
|
||||||
desc: Run Go tests.
|
desc: Run e2e tests
|
||||||
cmds:
|
cmds:
|
||||||
- go test ./... {{.CLI_ARGS}}
|
- go test ./e2e/... -v -count=1 {{.CLI_ARGS}}
|
||||||
|
|
||||||
create-bin-dir:
|
create-bin-dir:
|
||||||
desc: Create bin directory.
|
desc: Create bin directory.
|
||||||
|
|||||||
65
benchmark.sh
Executable file
65
benchmark.sh
Executable 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 "============================================"
|
||||||
@@ -10,8 +10,6 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
|||||||
- [General Functions](#general-functions)
|
- [General Functions](#general-functions)
|
||||||
- [String Functions](#string-functions)
|
- [String Functions](#string-functions)
|
||||||
- [Collection Functions](#collection-functions)
|
- [Collection Functions](#collection-functions)
|
||||||
- [Time Functions](#time-functions)
|
|
||||||
- [Crypto Functions](#crypto-functions)
|
|
||||||
- [Body Functions](#body-functions)
|
- [Body Functions](#body-functions)
|
||||||
- [File Functions](#file-functions)
|
- [File Functions](#file-functions)
|
||||||
- [Fake Data Functions](#fake-data-functions)
|
- [Fake Data Functions](#fake-data-functions)
|
||||||
@@ -111,24 +109,6 @@ sarin -U http://example.com/users \
|
|||||||
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
|
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
|
||||||
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
|
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 1 2 3 }}` |
|
||||||
|
|
||||||
### Time Functions
|
|
||||||
|
|
||||||
| Function | Description | Example |
|
|
||||||
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
||||||
| `time_NowUnix` | Current Unix timestamp (seconds) | `{{ time_NowUnix }}` → `1735689600` |
|
|
||||||
| `time_NowUnixMilli` | Current Unix timestamp (milliseconds) | `{{ time_NowUnixMilli }}` → `1735689600123` |
|
|
||||||
| `time_NowRFC3339` | Current time in RFC3339 format | `{{ time_NowRFC3339 }}` → `"2026-02-26T21:00:00Z"` |
|
|
||||||
| `time_Format(layout, t)` | Format a `time.Time` value with a Go layout | `{{ time_Format "2006-01-02" (strings_ToDate "2024-05-10") }}` → `"2024-05-10"` |
|
|
||||||
|
|
||||||
### Crypto Functions
|
|
||||||
|
|
||||||
| Function | Description | Example |
|
|
||||||
| ------------------------------------ | ------------------------------------------ | -------------------------------------------- |
|
|
||||||
| `crypto_SHA256(s string)` | SHA-256 hash (hex-encoded) | `{{ crypto_SHA256 "hello" }}` |
|
|
||||||
| `crypto_MD5(s string)` | MD5 hash (hex-encoded) | `{{ crypto_MD5 "hello" }}` |
|
|
||||||
| `crypto_HMACSHA256(key, msg string)` | HMAC-SHA256 signature (hex-encoded) | `{{ crypto_HMACSHA256 "secret" "payload" }}` |
|
|
||||||
| `crypto_Base64URL(s string)` | Base64 URL-safe encoding (without padding) | `{{ crypto_Base64URL "hello world" }}` |
|
|
||||||
|
|
||||||
### Body Functions
|
### Body Functions
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
@@ -173,18 +153,11 @@ body: '{{ body_FormData "twitter" "@@username" }}'
|
|||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
| `file_Read(source string)` | Read a file (local path or URL) and return raw content as string. Files are cached after first read. | `{{ file_Read "/path/to/file.txt" }}` |
|
|
||||||
| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` |
|
| `file_Base64(source string)` | Read a file (local path or URL) and return its Base64 encoded content. Files are cached after first read. | `{{ file_Base64 "/path/to/file.pdf" }}` |
|
||||||
|
|
||||||
**`file_Read` and `file_Base64` Details:**
|
**`file_Base64` Details:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Local file as plain text
|
|
||||||
body: '{{ file_Read "/path/to/template.json" }}'
|
|
||||||
|
|
||||||
# Remote text file
|
|
||||||
body: '{{ file_Read "https://example.com/payload.txt" }}'
|
|
||||||
|
|
||||||
# Local file as Base64 in JSON body
|
# Local file as Base64 in JSON body
|
||||||
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
|
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
|
||||||
|
|
||||||
|
|||||||
220
e2e/basic_test.go
Normal file
220
e2e/basic_test.go
Normal 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
401
e2e/config_file_test.go
Normal 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
282
e2e/config_merge_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
e2e/config_nested_http_test.go
Normal file
37
e2e/config_nested_http_test.go
Normal 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
117
e2e/coverage_gaps_test.go
Normal 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
316
e2e/e2e_test.go
Normal 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
87
e2e/env_errors_test.go
Normal 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
348
e2e/env_test.go
Normal 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
149
e2e/formdata_test.go
Normal 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
226
e2e/multi_value_test.go
Normal 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
198
e2e/output_test.go
Normal 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
103
e2e/proxy_test.go
Normal 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
331
e2e/request_test.go
Normal 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
137
e2e/script_errors_test.go
Normal 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
392
e2e/script_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
e2e/show_config_extra_test.go
Normal file
36
e2e/show_config_extra_test.go
Normal 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
61
e2e/show_config_test.go
Normal 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
116
e2e/signal_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
116
e2e/template_funcs_extra_test.go
Normal file
116
e2e/template_funcs_extra_test.go
Normal 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
170
e2e/template_funcs_test.go
Normal 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
241
e2e/template_test.go
Normal 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
110
e2e/timeout_test.go
Normal 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
164
e2e/tls_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
e2e/validation_extra_test.go
Normal file
13
e2e/validation_extra_test.go
Normal 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
168
e2e/validation_test.go
Normal 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")
|
||||||
|
}
|
||||||
47
go.mod
47
go.mod
@@ -1,46 +1,47 @@
|
|||||||
module go.aykhans.me/sarin
|
module go.aykhans.me/sarin
|
||||||
|
|
||||||
go 1.26.1
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.1
|
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||||
github.com/charmbracelet/bubbles v1.0.0
|
github.com/charmbracelet/bubbles v1.0.0
|
||||||
github.com/charmbracelet/bubbletea v1.3.10
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
github.com/charmbracelet/glamour v1.0.0
|
github.com/charmbracelet/glamour v0.10.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
github.com/charmbracelet/x/term v0.2.2
|
github.com/charmbracelet/x/term v0.2.2
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/valyala/fasthttp v1.70.0
|
github.com/valyala/fasthttp v1.69.0
|
||||||
github.com/yuin/gopher-lua v1.1.2
|
github.com/yuin/gopher-lua v1.1.1
|
||||||
go.aykhans.me/utils v1.0.7
|
go.aykhans.me/utils v1.0.7
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/net v0.52.0
|
golang.org/x/net v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
|
github.com/alecthomas/chroma/v2 v2.21.1 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||||
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
github.com/charmbracelet/harmonica v0.2.0 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971 // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
@@ -50,9 +51,9 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.7.16 // indirect
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/term v0.41.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
90
go.sum
90
go.sum
@@ -2,28 +2,28 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0
|
|||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
|
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||||
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
github.com/alecthomas/chroma/v2 v2.21.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
|
||||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.1 h1:a7fe3fonbj0cW3wgl5VwIKfZtiH9C3cLnwcIXWT7sow=
|
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||||
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
|
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||||
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
|
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||||
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
|
||||||
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
@@ -34,43 +34,45 @@ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMx
|
|||||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971 h1:wae/9jUCdhUiuyCcOzZZ+vJEB7uJx+IvtTnpCqcW1ZQ=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260329003944-7eda8903d971/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/go.mod h1:vqEfX6xzqW1pKKZUUiFOKg0OQ7bCh54Q2vR/tserrRA=
|
||||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
@@ -91,34 +93,34 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
|
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
|
||||||
github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
|
go.aykhans.me/utils v1.0.7 h1:ClHXHlWmkjfFlD7+w5BQY29lKCEztxY/yCf543x4hZw=
|
||||||
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
|
go.aykhans.me/utils v1.0.7/go.mod h1:0Jz8GlZLN35cCHLOLx39sazWwEe33bF6SYlSeqzEXoI=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4 h1:UP4+v6fFrBIb1l934bDl//mmnoIZEDK0idg1+AIvX5U=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.4/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ func (config Config) Validate() error {
|
|||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Duration", "0", errors.New("duration must be greater than 0")))
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Timeout == nil || *config.Timeout < 1 {
|
if *config.Timeout < 1 {
|
||||||
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
validationErrors = append(validationErrors, types.NewFieldValidationError("Timeout", "0", errors.New("timeout must be greater than 0")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
types.NewFieldParseError(
|
types.NewFieldParseError(
|
||||||
parser.getFullEnvName("DURATION"),
|
parser.getFullEnvName("DURATION"),
|
||||||
duration,
|
duration,
|
||||||
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -173,7 +173,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
types.NewFieldParseError(
|
types.NewFieldParseError(
|
||||||
parser.getFullEnvName("TIMEOUT"),
|
parser.getFullEnvName("TIMEOUT"),
|
||||||
timeout,
|
timeout,
|
||||||
errors.New("invalid value for duration, expected a duration string (e.g., '10s', '1h30m')"),
|
errors.New("invalid value duration, expected a duration string (e.g., '10s', '1h30m')"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
package sarin
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
captchaPollInterval = 5 * time.Second
|
|
||||||
captchaTimeout = 120 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
var captchaHTTPClient = &http.Client{Timeout: captchaTimeout}
|
|
||||||
|
|
||||||
// solveCaptcha creates a task and polls for the result.
|
|
||||||
// baseURL is the service API base (e.g. "https://api.2captcha.com").
|
|
||||||
// taskIDIsString controls whether taskId is sent back as a string or number.
|
|
||||||
// solutionKey is the field name in the solution object that holds the token.
|
|
||||||
func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) {
|
|
||||||
if apiKey == "" {
|
|
||||||
return "", types.ErrCaptchaKeyEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
taskID, err := captchaCreateTask(baseURL, apiKey, task)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
|
|
||||||
}
|
|
||||||
|
|
||||||
func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) {
|
|
||||||
body := map[string]any{
|
|
||||||
"clientKey": apiKey,
|
|
||||||
"task": task,
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return "", types.NewCaptchaRequestError("createTask", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := captchaHTTPClient.Post(
|
|
||||||
baseURL+"/createTask",
|
|
||||||
"application/json",
|
|
||||||
bytes.NewReader(data),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "", types.NewCaptchaRequestError("createTask", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
ErrorID int `json:"errorId"`
|
|
||||||
ErrorCode string `json:"errorCode"`
|
|
||||||
ErrorDescription string `json:"errorDescription"`
|
|
||||||
TaskID json.RawMessage `json:"taskId"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return "", types.NewCaptchaRequestError("createTask", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.ErrorID != 0 {
|
|
||||||
return "", types.NewCaptchaAPIError("createTask", result.ErrorCode, result.ErrorDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
// taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs).
|
|
||||||
// Strip surrounding quotes if present so we always work with the underlying value.
|
|
||||||
return strings.Trim(string(result.TaskID), `"`), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), captchaTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
ticker := time.NewTicker(captchaPollInterval)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return "", types.NewCaptchaTimeoutError(taskID)
|
|
||||||
case <-ticker.C:
|
|
||||||
token, done, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if done {
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, bool, error) {
|
|
||||||
var bodyMap map[string]any
|
|
||||||
if taskIDIsString {
|
|
||||||
bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID}
|
|
||||||
} else {
|
|
||||||
bodyMap = map[string]any{"clientKey": apiKey, "taskId": json.Number(taskID)}
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.Marshal(bodyMap)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, types.NewCaptchaRequestError("getTaskResult", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := captchaHTTPClient.Post(
|
|
||||||
baseURL+"/getTaskResult",
|
|
||||||
"application/json",
|
|
||||||
bytes.NewReader(data),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "", false, types.NewCaptchaRequestError("getTaskResult", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
|
||||||
|
|
||||||
var result struct {
|
|
||||||
ErrorID int `json:"errorId"`
|
|
||||||
ErrorCode string `json:"errorCode"`
|
|
||||||
ErrorDescription string `json:"errorDescription"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Solution map[string]any `json:"solution"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return "", false, types.NewCaptchaRequestError("getTaskResult", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.ErrorID != 0 {
|
|
||||||
return "", false, types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Status == "processing" || result.Status == "idle" {
|
|
||||||
return "", false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
token, ok := result.Solution[solutionKey]
|
|
||||||
if !ok {
|
|
||||||
return "", false, types.NewCaptchaSolutionKeyError(solutionKey)
|
|
||||||
}
|
|
||||||
tokenStr, ok := token.(string)
|
|
||||||
if !ok {
|
|
||||||
return "", false, types.NewCaptchaSolutionKeyError(solutionKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokenStr, true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================================== 2Captcha ========================================
|
|
||||||
|
|
||||||
const twoCaptchaBaseURL = "https://api.2captcha.com"
|
|
||||||
|
|
||||||
func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
|
||||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{
|
|
||||||
"type": "RecaptchaV2TaskProxyless",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}, "gRecaptchaResponse", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
|
||||||
task := map[string]any{
|
|
||||||
"type": "RecaptchaV3TaskProxyless",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}
|
|
||||||
if pageAction != "" {
|
|
||||||
task["pageAction"] = pageAction
|
|
||||||
}
|
|
||||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
|
||||||
task := map[string]any{
|
|
||||||
"type": "TurnstileTaskProxyless",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}
|
|
||||||
if cData != "" {
|
|
||||||
task["data"] = cData
|
|
||||||
}
|
|
||||||
return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "token", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================================== Anti-Captcha ========================================
|
|
||||||
|
|
||||||
const antiCaptchaBaseURL = "https://api.anti-captcha.com"
|
|
||||||
|
|
||||||
func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
|
||||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
|
||||||
"type": "RecaptchaV2TaskProxyless",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}, "gRecaptchaResponse", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
|
||||||
// Anti-Captcha requires minScore for reCAPTCHA v3. 0.3 is the loosest threshold.
|
|
||||||
task := map[string]any{
|
|
||||||
"type": "RecaptchaV3TaskProxyless",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
"minScore": 0.3,
|
|
||||||
}
|
|
||||||
if pageAction != "" {
|
|
||||||
task["pageAction"] = pageAction
|
|
||||||
}
|
|
||||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) {
|
|
||||||
// Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token").
|
|
||||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{
|
|
||||||
"type": "HCaptchaTaskProxyless",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}, "gRecaptchaResponse", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
|
||||||
task := map[string]any{
|
|
||||||
"type": "TurnstileTaskProxyless",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}
|
|
||||||
if cData != "" {
|
|
||||||
task["cData"] = cData
|
|
||||||
}
|
|
||||||
return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "token", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================================== CapSolver ========================================
|
|
||||||
|
|
||||||
const capSolverBaseURL = "https://api.capsolver.com"
|
|
||||||
|
|
||||||
func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) {
|
|
||||||
return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{
|
|
||||||
"type": "ReCaptchaV2TaskProxyLess",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}, "gRecaptchaResponse", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) {
|
|
||||||
task := map[string]any{
|
|
||||||
"type": "ReCaptchaV3TaskProxyLess",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}
|
|
||||||
if pageAction != "" {
|
|
||||||
task["pageAction"] = pageAction
|
|
||||||
}
|
|
||||||
return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) {
|
|
||||||
task := map[string]any{
|
|
||||||
"type": "AntiTurnstileTaskProxyLess",
|
|
||||||
"websiteURL": websiteURL,
|
|
||||||
"websiteKey": websiteKey,
|
|
||||||
}
|
|
||||||
if cData != "" {
|
|
||||||
task["metadata"] = map[string]any{"cdata": cData}
|
|
||||||
}
|
|
||||||
return solveCaptcha(capSolverBaseURL, apiKey, task, "token", true)
|
|
||||||
}
|
|
||||||
@@ -172,6 +172,7 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
return nil, types.NewProxyDialError(proxyStr, err)
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cap DNS resolution to half the timeout to reserve time for dial
|
||||||
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
dnsCtx, dnsCancel := context.WithTimeout(ctx, timeout)
|
||||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
||||||
dnsCancel()
|
dnsCancel()
|
||||||
@@ -243,7 +244,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to TLS
|
// Upgrade to TLS
|
||||||
tlsConn := tls.Client(conn, &tls.Config{
|
tlsConn := tls.Client(conn, &tls.Config{ //nolint:gosec
|
||||||
ServerName: proxyURL.Hostname(),
|
ServerName: proxyURL.Hostname(),
|
||||||
})
|
})
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
|||||||
@@ -8,14 +8,7 @@ import (
|
|||||||
func NewDefaultRandSource() rand.Source {
|
func NewDefaultRandSource() rand.Source {
|
||||||
now := time.Now().UnixNano()
|
now := time.Now().UnixNano()
|
||||||
return rand.NewPCG(
|
return rand.NewPCG(
|
||||||
uint64(now),
|
uint64(now), //nolint:gosec // G115: Safe conversion; UnixNano timestamp used as random seed, bit pattern is intentional
|
||||||
uint64(now>>32),
|
uint64(now>>32), //nolint:gosec // G115: Safe conversion; right-shifted timestamp for seed entropy, overflow is acceptable
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func firstOrEmpty(values []string) string {
|
|
||||||
if len(values) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return values[0]
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -43,34 +43,19 @@ func NewRequestGenerator(
|
|||||||
randSource := NewDefaultRandSource()
|
randSource := NewDefaultRandSource()
|
||||||
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
|
//nolint:gosec // G404: Using non-cryptographic rand for load testing, not security
|
||||||
localRand := rand.New(randSource)
|
localRand := rand.New(randSource)
|
||||||
|
templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
|
||||||
|
|
||||||
// Funcs() is only called if a value actually contains template syntax.
|
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
|
||||||
// The root template is shared across all createTemplateFunc calls so Funcs() is called at most once.
|
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
|
||||||
var templateRoot *template.Template
|
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, templateFuncMap)
|
||||||
lazyTemplateRoot := func() *template.Template {
|
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, templateFuncMap)
|
||||||
if templateRoot == nil {
|
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
|
||||||
templateRoot = template.New("").Funcs(NewDefaultTemplateFuncMap(randSource, fileCache))
|
|
||||||
}
|
|
||||||
return templateRoot
|
|
||||||
}
|
|
||||||
|
|
||||||
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, lazyTemplateRoot)
|
|
||||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, lazyTemplateRoot)
|
|
||||||
paramsGenerator, isParamsGeneratorDynamic := NewParamsGeneratorFunc(localRand, params, lazyTemplateRoot)
|
|
||||||
headersGenerator, isHeadersGeneratorDynamic := NewHeadersGeneratorFunc(localRand, headers, lazyTemplateRoot)
|
|
||||||
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, lazyTemplateRoot)
|
|
||||||
|
|
||||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||||
var bodyTemplateRoot *template.Template
|
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
|
||||||
lazyBodyTemplateRoot := func() *template.Template {
|
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
|
||||||
if bodyTemplateRoot == nil {
|
|
||||||
bodyTemplateRoot = template.New("").Funcs(NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache))
|
|
||||||
}
|
|
||||||
return bodyTemplateRoot
|
|
||||||
}
|
|
||||||
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, lazyBodyTemplateRoot)
|
|
||||||
|
|
||||||
valuesGenerator := NewValuesGeneratorFunc(values, lazyTemplateRoot)
|
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
|
||||||
|
|
||||||
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
|
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
|
||||||
|
|
||||||
@@ -106,7 +91,7 @@ func NewRequestGenerator(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyTemplateFuncMapData.ClearFormDataContentType()
|
bodyTemplateFuncMapData.ClearFormDataContenType()
|
||||||
if err = bodyGenerator(reqData, data); err != nil {
|
if err = bodyGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -114,8 +99,8 @@ func NewRequestGenerator(
|
|||||||
if err = headersGenerator(reqData, data); err != nil {
|
if err = headersGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if bodyTemplateFuncMapData.GetFormDataContentType() != "" {
|
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
|
||||||
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContentType())
|
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContenType())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = paramsGenerator(reqData, data); err != nil {
|
if err = paramsGenerator(reqData, data); err != nil {
|
||||||
@@ -185,8 +170,8 @@ func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Reque
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, lazyRoot)
|
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
method string
|
method string
|
||||||
@@ -203,8 +188,8 @@ func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, lazyRoot fun
|
|||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, lazyRoot)
|
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
body string
|
body string
|
||||||
@@ -221,8 +206,8 @@ func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, lazyRoot func()
|
|||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, lazyRoot)
|
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
@@ -246,8 +231,8 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, lazyRoot
|
|||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, lazyRoot)
|
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
@@ -271,8 +256,8 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, lazyRo
|
|||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRoot func() *template.Template) (requestDataGenerator, bool) {
|
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, lazyRoot)
|
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
@@ -296,11 +281,11 @@ func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, lazyRo
|
|||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template) func() (valuesData, error) {
|
func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap) func() (valuesData, error) {
|
||||||
generators := make([]func(_ any) (string, error), len(values))
|
generators := make([]func(_ any) (string, error), len(values))
|
||||||
|
|
||||||
for i, v := range values {
|
for i, v := range values {
|
||||||
generators[i], _ = createTemplateFunc(v, lazyRoot)
|
generators[i], _ = createTemplateFunc(v, templateFunctions)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -328,12 +313,8 @@ func NewValuesGeneratorFunc(values []string, lazyRoot func() *template.Template)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createTemplateFunc(value string, lazyRoot func() *template.Template) (func(data any) (string, error), bool) {
|
func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(data any) (string, error), bool) {
|
||||||
if !strings.Contains(value, "{{") {
|
tmpl, err := template.New("").Funcs(templateFunctions).Parse(value)
|
||||||
return func(_ any) (string, error) { return value, nil }, false
|
|
||||||
}
|
|
||||||
|
|
||||||
tmpl, err := lazyRoot().New("").Parse(value)
|
|
||||||
if err == nil && hasTemplateActions(tmpl) {
|
if err == nil && hasTemplateActions(tmpl) {
|
||||||
var err error
|
var err error
|
||||||
return func(data any) (string, error) {
|
return func(data any) (string, error) {
|
||||||
@@ -359,7 +340,7 @@ type keyValueItem interface {
|
|||||||
func buildKeyValueGenerators[T keyValueItem](
|
func buildKeyValueGenerators[T keyValueItem](
|
||||||
localRand *rand.Rand,
|
localRand *rand.Rand,
|
||||||
items []T,
|
items []T,
|
||||||
lazyRoot func() *template.Template,
|
templateFunctions template.FuncMap,
|
||||||
) ([]keyValueGenerator, bool) {
|
) ([]keyValueGenerator, bool) {
|
||||||
isDynamic := false
|
isDynamic := false
|
||||||
generators := make([]keyValueGenerator, len(items))
|
generators := make([]keyValueGenerator, len(items))
|
||||||
@@ -369,7 +350,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
keyValue := types.KeyValue[string, []string](item)
|
keyValue := types.KeyValue[string, []string](item)
|
||||||
|
|
||||||
// Generate key function
|
// Generate key function
|
||||||
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, lazyRoot)
|
keyFunc, keyIsDynamic := createTemplateFunc(keyValue.Key, templateFunctions)
|
||||||
if keyIsDynamic {
|
if keyIsDynamic {
|
||||||
isDynamic = true
|
isDynamic = true
|
||||||
}
|
}
|
||||||
@@ -377,7 +358,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
// Generate value functions
|
// Generate value functions
|
||||||
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
|
valueFuncs := make([]func(data any) (string, error), len(keyValue.Value))
|
||||||
for j, v := range keyValue.Value {
|
for j, v := range keyValue.Value {
|
||||||
valueFunc, valueIsDynamic := createTemplateFunc(v, lazyRoot)
|
valueFunc, valueIsDynamic := createTemplateFunc(v, templateFunctions)
|
||||||
if valueIsDynamic {
|
if valueIsDynamic {
|
||||||
isDynamic = true
|
isDynamic = true
|
||||||
}
|
}
|
||||||
@@ -400,7 +381,7 @@ func buildKeyValueGenerators[T keyValueItem](
|
|||||||
func buildStringSliceGenerator(
|
func buildStringSliceGenerator(
|
||||||
localRand *rand.Rand,
|
localRand *rand.Rand,
|
||||||
values []string,
|
values []string,
|
||||||
lazyRoot func() *template.Template,
|
templateFunctions template.FuncMap,
|
||||||
) (func() func(data any) (string, error), bool) {
|
) (func() func(data any) (string, error), bool) {
|
||||||
// Return a function that returns an empty string generator if values is empty
|
// Return a function that returns an empty string generator if values is empty
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
@@ -412,7 +393,7 @@ func buildStringSliceGenerator(
|
|||||||
valueFuncs := make([]func(data any) (string, error), len(values))
|
valueFuncs := make([]func(data any) (string, error), len(values))
|
||||||
|
|
||||||
for i, value := range values {
|
for i, value := range values {
|
||||||
valueFunc, valueIsDynamic := createTemplateFunc(value, lazyRoot)
|
valueFunc, valueIsDynamic := createTemplateFunc(value, templateFunctions)
|
||||||
if valueIsDynamic {
|
if valueIsDynamic {
|
||||||
isDynamic = true
|
isDynamic = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -484,11 +484,13 @@ func newHostClients(
|
|||||||
proxiesRaw[i] = url.URL(proxy)
|
proxiesRaw[i] = url.URL(proxy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
maxConns := max(fasthttp.DefaultMaxConnsPerHost, workers)
|
||||||
|
maxConns = ((maxConns * 50 / 100) + maxConns)
|
||||||
return NewHostClients(
|
return NewHostClients(
|
||||||
ctx,
|
ctx,
|
||||||
timeout,
|
timeout,
|
||||||
proxiesRaw,
|
proxiesRaw,
|
||||||
workers,
|
maxConns,
|
||||||
requestURL,
|
requestURL,
|
||||||
skipCertVerify,
|
skipCertVerify,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/md5" // #nosec G501 -- exposed intentionally as a template utility helper
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -85,47 +81,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"slice_Uint": func(values ...uint) []uint { return values },
|
"slice_Uint": func(values ...uint) []uint { return values },
|
||||||
"slice_Join": strings.Join,
|
"slice_Join": strings.Join,
|
||||||
|
|
||||||
// Time
|
|
||||||
"time_NowUnix": func() int64 { return time.Now().Unix() },
|
|
||||||
"time_NowUnixMilli": func() int64 { return time.Now().UnixMilli() },
|
|
||||||
"time_NowRFC3339": func() string { return time.Now().Format(time.RFC3339) },
|
|
||||||
"time_Format": func(layout string, t time.Time) string {
|
|
||||||
return t.Format(layout)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Crypto
|
|
||||||
"crypto_SHA256": func(s string) string {
|
|
||||||
sum := sha256.Sum256([]byte(s))
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
},
|
|
||||||
"crypto_MD5": func(s string) string {
|
|
||||||
sum := md5.Sum([]byte(s)) // #nosec G401 -- MD5 is intentionally provided as a non-security template helper
|
|
||||||
return hex.EncodeToString(sum[:])
|
|
||||||
},
|
|
||||||
"crypto_HMACSHA256": func(key string, msg string) string {
|
|
||||||
mac := hmac.New(sha256.New, []byte(key))
|
|
||||||
_, _ = mac.Write([]byte(msg))
|
|
||||||
return hex.EncodeToString(mac.Sum(nil))
|
|
||||||
},
|
|
||||||
"crypto_Base64URL": func(s string) string {
|
|
||||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
|
||||||
},
|
|
||||||
|
|
||||||
// File
|
// File
|
||||||
// file_Read reads a file (local or remote URL) and returns its content as a string.
|
|
||||||
// Usage: {{ file_Read "/path/to/file.txt" }}
|
|
||||||
// {{ file_Read "https://example.com/data.txt" }}
|
|
||||||
"file_Read": func(source string) (string, error) {
|
|
||||||
if fileCache == nil {
|
|
||||||
return "", types.ErrFileCacheNotInitialized
|
|
||||||
}
|
|
||||||
cached, err := fileCache.GetOrLoad(source)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(cached.Content), nil
|
|
||||||
},
|
|
||||||
|
|
||||||
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
|
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
|
||||||
// Usage: {{ file_Base64 "/path/to/file.pdf" }}
|
// Usage: {{ file_Base64 "/path/to/file.pdf" }}
|
||||||
// {{ file_Base64 "https://example.com/image.png" }}
|
// {{ file_Base64 "https://example.com/image.png" }}
|
||||||
@@ -286,7 +242,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
|
"fakeit_AdverbFrequencyDefinite": fakeit.AdverbFrequencyDefinite,
|
||||||
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
|
"fakeit_AdverbFrequencyIndefinite": fakeit.AdverbFrequencyIndefinite,
|
||||||
|
|
||||||
// Prepositions
|
// Propositions
|
||||||
"fakeit_Preposition": fakeit.Preposition,
|
"fakeit_Preposition": fakeit.Preposition,
|
||||||
"fakeit_PrepositionSimple": fakeit.PrepositionSimple,
|
"fakeit_PrepositionSimple": fakeit.PrepositionSimple,
|
||||||
"fakeit_PrepositionDouble": fakeit.PrepositionDouble,
|
"fakeit_PrepositionDouble": fakeit.PrepositionDouble,
|
||||||
@@ -574,7 +530,8 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
"fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() },
|
||||||
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
"fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() },
|
||||||
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
|
"fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() },
|
||||||
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
|
// "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() },
|
||||||
|
"fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() },
|
||||||
|
|
||||||
// Fakeit / School
|
// Fakeit / School
|
||||||
"fakeit_School": fakeit.School,
|
"fakeit_School": fakeit.School,
|
||||||
@@ -584,68 +541,19 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem
|
|||||||
"fakeit_SongName": fakeit.SongName,
|
"fakeit_SongName": fakeit.SongName,
|
||||||
"fakeit_SongArtist": fakeit.SongArtist,
|
"fakeit_SongArtist": fakeit.SongArtist,
|
||||||
"fakeit_SongGenre": fakeit.SongGenre,
|
"fakeit_SongGenre": fakeit.SongGenre,
|
||||||
|
|
||||||
// Captcha / 2Captcha
|
|
||||||
// Usage: {{ twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
|
||||||
"twocaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
|
||||||
return twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
|
||||||
},
|
|
||||||
// Usage: {{ twocaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
|
||||||
"twocaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
|
||||||
return twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
|
||||||
},
|
|
||||||
// Usage: {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
|
||||||
// {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
|
||||||
"twocaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
|
||||||
return twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
|
||||||
},
|
|
||||||
|
|
||||||
// Captcha / Anti-Captcha
|
|
||||||
// Usage: {{ anticaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
|
||||||
"anticaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
|
||||||
return antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
|
||||||
},
|
|
||||||
// Usage: {{ anticaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
|
||||||
"anticaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
|
||||||
return antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
|
||||||
},
|
|
||||||
// Usage: {{ anticaptcha_HCaptcha "API_KEY" "SITE_KEY" "https://example.com" }}
|
|
||||||
"anticaptcha_HCaptcha": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
|
||||||
return antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey)
|
|
||||||
},
|
|
||||||
// Usage: {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
|
||||||
// {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
|
||||||
"anticaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
|
||||||
return antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
|
||||||
},
|
|
||||||
|
|
||||||
// Captcha / CapSolver
|
|
||||||
// Usage: {{ capsolver_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }}
|
|
||||||
"capsolver_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) {
|
|
||||||
return capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey)
|
|
||||||
},
|
|
||||||
// Usage: {{ capsolver_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }}
|
|
||||||
"capsolver_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) {
|
|
||||||
return capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction)
|
|
||||||
},
|
|
||||||
// Usage: {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }}
|
|
||||||
// {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }}
|
|
||||||
"capsolver_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) {
|
|
||||||
return capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData))
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type BodyTemplateFuncMapData struct {
|
type BodyTemplateFuncMapData struct {
|
||||||
formDataContentType string
|
formDataContenType string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (data BodyTemplateFuncMapData) GetFormDataContentType() string {
|
func (data BodyTemplateFuncMapData) GetFormDataContenType() string {
|
||||||
return data.formDataContentType
|
return data.formDataContenType
|
||||||
}
|
}
|
||||||
|
|
||||||
func (data *BodyTemplateFuncMapData) ClearFormDataContentType() {
|
func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
|
||||||
data.formDataContentType = ""
|
data.formDataContenType = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDefaultBodyTemplateFuncMap(
|
func NewDefaultBodyTemplateFuncMap(
|
||||||
@@ -676,7 +584,7 @@ func NewDefaultBodyTemplateFuncMap(
|
|||||||
|
|
||||||
var multipartData bytes.Buffer
|
var multipartData bytes.Buffer
|
||||||
writer := multipart.NewWriter(&multipartData)
|
writer := multipart.NewWriter(&multipartData)
|
||||||
data.formDataContentType = writer.FormDataContentType()
|
data.formDataContenType = writer.FormDataContentType()
|
||||||
|
|
||||||
for i := 0; i < len(pairs); i += 2 {
|
for i := 0; i < len(pairs); i += 2 {
|
||||||
key := pairs[i]
|
key := pairs[i]
|
||||||
|
|||||||
@@ -442,65 +442,3 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
|
|||||||
func (e ScriptUnknownEngineError) Error() string {
|
func (e ScriptUnknownEngineError) Error() string {
|
||||||
return "unknown engine type: " + e.EngineType
|
return "unknown engine type: " + e.EngineType
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================================== Captcha ========================================
|
|
||||||
|
|
||||||
var ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty")
|
|
||||||
|
|
||||||
type CaptchaAPIError struct {
|
|
||||||
Endpoint string
|
|
||||||
Code string
|
|
||||||
Description string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCaptchaAPIError(endpoint, code, description string) CaptchaAPIError {
|
|
||||||
return CaptchaAPIError{Endpoint: endpoint, Code: code, Description: description}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e CaptchaAPIError) Error() string {
|
|
||||||
return fmt.Sprintf("captcha %s error: %s (%s)", e.Endpoint, e.Code, e.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CaptchaRequestError struct {
|
|
||||||
Endpoint string
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCaptchaRequestError(endpoint string, err error) CaptchaRequestError {
|
|
||||||
if err == nil {
|
|
||||||
err = errNoError
|
|
||||||
}
|
|
||||||
return CaptchaRequestError{Endpoint: endpoint, Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e CaptchaRequestError) Error() string {
|
|
||||||
return fmt.Sprintf("captcha %s request failed: %v", e.Endpoint, e.Err)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e CaptchaRequestError) Unwrap() error {
|
|
||||||
return e.Err
|
|
||||||
}
|
|
||||||
|
|
||||||
type CaptchaTimeoutError struct {
|
|
||||||
TaskID string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCaptchaTimeoutError(taskID string) CaptchaTimeoutError {
|
|
||||||
return CaptchaTimeoutError{TaskID: taskID}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e CaptchaTimeoutError) Error() string {
|
|
||||||
return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID)
|
|
||||||
}
|
|
||||||
|
|
||||||
type CaptchaSolutionKeyError struct {
|
|
||||||
Key string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCaptchaSolutionKeyError(key string) CaptchaSolutionKeyError {
|
|
||||||
return CaptchaSolutionKeyError{Key: key}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e CaptchaSolutionKeyError) Error() string {
|
|
||||||
return fmt.Sprintf("captcha solution missing expected key %q", e.Key)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user