mirror of
https://github.com/aykhans/sarin.git
synced 2026-02-28 14:59:14 +00:00
Compare commits
28 Commits
a9738c0a11
...
feat/e2e-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c246102ff | |||
| 4b3230bb27 | |||
| d197e90103 | |||
| ae054bb3d6 | |||
| 61af28a3d3 | |||
| 665be5d98a | |||
| d346067e8a | |||
| a3e20cd3d3 | |||
| 6d921cf8e3 | |||
|
|
d8b0a1e6a3 | ||
| b21d97192c | |||
| f0606a0f82 | |||
| 3be8ff218c | |||
| 7cb49195f8 | |||
|
|
a154215495 | ||
| c1584eb47b | |||
| 6a713ef241 | |||
| 6dafc082ed | |||
| e83eacf380 | |||
| c2ba1844ab | |||
|
|
054e5fd253 | ||
| 533ced4b54 | |||
| c3ea3a34ad | |||
|
|
c02a079d2a | ||
|
|
f78942bfb6 | ||
| 1369cb9f09 | |||
| 18662e6a64 | |||
| 81f08edc8d |
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
|
||||||
8
.github/workflows/lint.yaml
vendored
8
.github/workflows/lint.yaml
vendored
@@ -16,8 +16,12 @@ jobs:
|
|||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version: 1.25.5
|
go-version: 1.26.0
|
||||||
|
- name: go fix
|
||||||
|
run: |
|
||||||
|
go fix ./...
|
||||||
|
git diff --exit-code
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: v2.7.2
|
version: v2.9.0
|
||||||
|
|||||||
14
.github/workflows/release.yaml
vendored
14
.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.25.5" >> $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
|
||||||
@@ -53,12 +53,12 @@ jobs:
|
|||||||
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
|
-X 'go.aykhans.me/sarin/internal/version.GoVersion=$(go version)' \
|
||||||
-s -w"
|
-s -w"
|
||||||
|
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-amd64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-linux-arm64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-amd64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-darwin-arm64 ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-amd64.exe ./cmd/cli/main.go
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
|
CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -ldflags "$LDFLAGS" -o ./sarin-windows-arm64.exe ./cmd/cli/main.go
|
||||||
|
|
||||||
- name: Upload Release Assets
|
- name: Upload Release Assets
|
||||||
if: github.event_name == 'release' || inputs.build_binaries
|
if: github.event_name == 'release' || inputs.build_binaries
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.25"
|
go: "1.26"
|
||||||
concurrency: 12
|
concurrency: 12
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
@@ -29,7 +29,6 @@ linters:
|
|||||||
- errorlint
|
- errorlint
|
||||||
- exptostd
|
- exptostd
|
||||||
- fatcontext
|
- fatcontext
|
||||||
- forcetypeassert
|
|
||||||
- funcorder
|
- funcorder
|
||||||
- gocheckcompilerdirectives
|
- gocheckcompilerdirectives
|
||||||
- gocritic
|
- gocritic
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
ARG GO_VERSION=1.25.5
|
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
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ RUN --mount=type=bind,source=./go.mod,target=./go.mod \
|
|||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
RUN --mount=type=bind,source=./,target=./ \
|
RUN --mount=type=bind,source=./,target=./ \
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
|
CGO_ENABLED=0 go build \
|
||||||
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=${VERSION}' \
|
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=${VERSION}' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${GIT_COMMIT}' \
|
-X 'go.aykhans.me/sarin/internal/version.GitCommit=${GIT_COMMIT}' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
## Sarin is a high-performance HTTP load testing tool built with Go and fasthttp.
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/go.aykhans.me/sarin)
|
||||||
|
[](https://goreportcard.com/report/go.aykhans.me/sarin)
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|

|
||||||
@@ -18,13 +22,14 @@
|
|||||||
|
|
||||||
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
|
Sarin is designed for efficient HTTP load testing with minimal resource consumption. It prioritizes simplicity—features like templating add zero overhead when unused.
|
||||||
|
|
||||||
| ✅ Supported | ❌ Not Supported |
|
| ✅ Supported | ❌ Not Supported |
|
||||||
| ---------------------------------------------------------- | --------------------------------- |
|
| ---------------------------------------------------------- | ------------------------------- |
|
||||||
| High-performance with low memory footprint | Detailed response body analysis |
|
| High-performance with low memory footprint | Detailed response body analysis |
|
||||||
| Long-running duration/count based tests | Extensive response statistics |
|
| Long-running duration/count based tests | Extensive response statistics |
|
||||||
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
|
| Dynamic requests via 320+ template functions | Web UI or complex TUI |
|
||||||
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | Scripting or multi-step scenarios |
|
| Request scripting with Lua and JavaScript | Distributed load testing |
|
||||||
| Flexible config (CLI, ENV, YAML) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
| Multiple proxy protocols<br>(HTTP, HTTPS, SOCKS5, SOCKS5H) | HTTP/2, HTTP/3, WebSocket, gRPC |
|
||||||
|
| Flexible config (CLI, ENV, YAML) | Plugins / extensions ecosystem |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -52,12 +57,12 @@ Download the latest binaries from the [releases](https://github.com/aykhans/sari
|
|||||||
|
|
||||||
### Building from Source
|
### Building from Source
|
||||||
|
|
||||||
Requires [Go 1.25+](https://golang.org/dl/).
|
Requires [Go 1.26+](https://golang.org/dl/).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://github.com/aykhans/sarin.git && cd sarin
|
git clone https://github.com/aykhans/sarin.git && cd sarin
|
||||||
|
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build \
|
CGO_ENABLED=0 go build \
|
||||||
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=dev' \
|
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=dev' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)' \
|
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)' \
|
||||||
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)' \
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: "3"
|
|||||||
|
|
||||||
vars:
|
vars:
|
||||||
BIN_DIR: ./bin
|
BIN_DIR: ./bin
|
||||||
GOLANGCI_LINT_VERSION: v2.7.2
|
GOLANGCI_LINT_VERSION: v2.9.0
|
||||||
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
GOLANGCI: "{{.BIN_DIR}}/golangci-lint-{{.GOLANGCI_LINT_VERSION}}"
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
@@ -11,16 +11,22 @@ tasks:
|
|||||||
desc: Run fmt, tidy, and lint.
|
desc: Run fmt, tidy, and lint.
|
||||||
cmds:
|
cmds:
|
||||||
- task: fmt
|
- task: fmt
|
||||||
|
- task: fix
|
||||||
- task: tidy
|
- task: tidy
|
||||||
- task: lint
|
- task: lint
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
desc: Run linters
|
desc: Run format
|
||||||
deps:
|
deps:
|
||||||
- install-golangci-lint
|
- install-golangci-lint
|
||||||
cmds:
|
cmds:
|
||||||
- "{{.GOLANGCI}} fmt"
|
- "{{.GOLANGCI}} fmt"
|
||||||
|
|
||||||
|
fix:
|
||||||
|
desc: Run go fix
|
||||||
|
cmds:
|
||||||
|
- go fix ./...
|
||||||
|
|
||||||
tidy:
|
tidy:
|
||||||
desc: Run go mod tidy.
|
desc: Run go mod tidy.
|
||||||
cmds:
|
cmds:
|
||||||
@@ -33,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.
|
||||||
@@ -52,7 +58,7 @@ tasks:
|
|||||||
cmds:
|
cmds:
|
||||||
- rm -f {{.OUTPUT}}
|
- rm -f {{.OUTPUT}}
|
||||||
- >-
|
- >-
|
||||||
CGO_ENABLED=0 GOEXPERIMENT=greenteagc go build
|
CGO_ENABLED=0 go build
|
||||||
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=$(git describe --tags --always)'
|
-ldflags "-X 'go.aykhans.me/sarin/internal/version.Version=$(git describe --tags --always)'
|
||||||
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)'
|
-X 'go.aykhans.me/sarin/internal/version.GitCommit=$(git rev-parse HEAD)'
|
||||||
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'
|
-X 'go.aykhans.me/sarin/internal/version.BuildDate=$(date -u +%Y-%m-%dT%H:%M:%SZ)'
|
||||||
|
|||||||
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 "============================================"
|
||||||
@@ -53,6 +53,7 @@ func main() {
|
|||||||
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
combinedConfig.Cookies, combinedConfig.Bodies, combinedConfig.Proxies, combinedConfig.Values,
|
||||||
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
*combinedConfig.Output != config.ConfigOutputTypeNone,
|
||||||
*combinedConfig.DryRun,
|
*combinedConfig.DryRun,
|
||||||
|
combinedConfig.Lua, combinedConfig.Js,
|
||||||
)
|
)
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnType(func(err types.ProxyDialError) error {
|
utilsErr.OnType(func(err types.ProxyDialError) error {
|
||||||
@@ -60,6 +61,16 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
|
utilsErr.OnSentinel(types.ErrScriptEmpty, func(err error) error {
|
||||||
|
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
utilsErr.OnType(func(err types.ScriptLoadError) error {
|
||||||
|
fmt.Fprintln(os.Stderr, config.StyleRed.Render("[SCRIPT] ")+err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
srn.Start(ctx)
|
srn.Start(ctx)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ Use `-s` or `--show-config` to see the final merged configuration before sending
|
|||||||
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
| [Cookies](#cookies) | `cookies`<br>(object) | `-cookie` / `-C`<br>(string / []string) | `SARIN_COOKIE`<br>(string) | - | HTTP cookies |
|
||||||
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
| [Proxy](#proxy) | `proxy`<br>(string / []string) | `-proxy` / `-X`<br>(string / []string) | `SARIN_PROXY`<br>(string) | - | Proxy URL(s) |
|
||||||
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
| [Values](#values) | `values`<br>(string / []string) | `-values` / `-V`<br>(string / []string) | `SARIN_VALUES`<br>(string) | - | Template values (key=value) |
|
||||||
|
| [Lua](#lua) | `lua`<br>(string / []string) | `-lua`<br>(string / []string) | `SARIN_LUA`<br>(string) | - | Lua script(s) |
|
||||||
|
| [Js](#js) | `js`<br>(string / []string) | `-js`<br>(string / []string) | `SARIN_JS`<br>(string) | - | JavaScript script(s) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -103,6 +105,12 @@ SARIN_CONFIG_FILE=/config1.yaml sarin -f /config2.yaml -f https://example.com/co
|
|||||||
|
|
||||||
If all four files define `url`, the value from `config3.yaml` wins.
|
If all four files define `url`, the value from `config3.yaml` wins.
|
||||||
|
|
||||||
|
**Merge behavior by field:**
|
||||||
|
|
||||||
|
- **Scalar fields** (`url`, `requests`, `duration`, `timeout`, `concurrency`, etc.) — higher priority overrides lower priority
|
||||||
|
- **Method and Body** — higher priority overrides lower priority (no merging)
|
||||||
|
- **Headers, Params, Cookies, Proxies, Values, Lua, and Js** — accumulated across all config files
|
||||||
|
|
||||||
## URL
|
## URL
|
||||||
|
|
||||||
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
|
Target URL. Must be HTTP or HTTPS. The URL path supports [templating](templating.md), allowing dynamic path generation per request.
|
||||||
@@ -225,26 +233,33 @@ SARIN_BODY='{"product": "car"}'
|
|||||||
|
|
||||||
## Params
|
## Params
|
||||||
|
|
||||||
URL query parameters. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
URL query parameters. Supports [templating](templating.md).
|
||||||
|
|
||||||
|
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
params:
|
params:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3]
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
params:
|
params:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3]
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
|
# To send both values in every request, use separate entries:
|
||||||
|
params:
|
||||||
|
- key2: value2
|
||||||
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
-param "key1=value1" -param "key2=value2" -param "key2=value3"
|
-param "key1=value1" -param "key2=value2" -param "key2=value3" # sends both value2 and value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**ENV example:**
|
**ENV example:**
|
||||||
@@ -255,26 +270,33 @@ SARIN_PARAM="key1=value1"
|
|||||||
|
|
||||||
## Headers
|
## Headers
|
||||||
|
|
||||||
HTTP headers. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
HTTP headers. Supports [templating](templating.md).
|
||||||
|
|
||||||
|
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
headers:
|
headers:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3]
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
headers:
|
headers:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3]
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
|
# To send both values in every request, use separate entries:
|
||||||
|
headers:
|
||||||
|
- key2: value2
|
||||||
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
-header "key1: value1" -header "key2: value2" -header "key2: value3"
|
-header "key1: value1" -header "key2: value2" -header "key2: value3" # sends both value2 and value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**ENV example:**
|
**ENV example:**
|
||||||
@@ -285,26 +307,33 @@ SARIN_HEADER="key1: value1"
|
|||||||
|
|
||||||
## Cookies
|
## Cookies
|
||||||
|
|
||||||
HTTP cookies. If multiple values are provided for a key, Sarin cycles through them in order, starting from a random index for each request. Supports [templating](templating.md).
|
HTTP cookies. Supports [templating](templating.md).
|
||||||
|
|
||||||
|
When the same key appears as **separate entries** (in CLI or config file), all values are sent in every request. When multiple values are specified as an **array on a single key** (config file only), Sarin cycles through them.
|
||||||
|
|
||||||
**YAML example:**
|
**YAML example:**
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
cookies:
|
cookies:
|
||||||
key1: value1
|
key1: value1
|
||||||
key2: [value2, value3]
|
key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
# OR
|
# OR
|
||||||
|
|
||||||
cookies:
|
cookies:
|
||||||
- key1: value1
|
- key1: value1
|
||||||
- key2: [value2, value3]
|
- key2: [value2, value3] # cycles between value2 and value3
|
||||||
|
|
||||||
|
# To send both values in every request, use separate entries:
|
||||||
|
cookies:
|
||||||
|
- key2: value2
|
||||||
|
- key2: value3 # both sent in every request
|
||||||
```
|
```
|
||||||
|
|
||||||
**CLI example:**
|
**CLI example:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3"
|
-cookie "key1=value1" -cookie "key2=value2" -cookie "key2=value3" # sends both value2 and value3
|
||||||
```
|
```
|
||||||
|
|
||||||
**ENV example:**
|
**ENV example:**
|
||||||
@@ -374,3 +403,133 @@ values: |
|
|||||||
```sh
|
```sh
|
||||||
SARIN_VALUES="key1=value1"
|
SARIN_VALUES="key1=value1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Lua
|
||||||
|
|
||||||
|
Lua script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||||
|
|
||||||
|
If multiple Lua scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||||
|
|
||||||
|
**Script sources:**
|
||||||
|
|
||||||
|
Scripts can be provided as:
|
||||||
|
|
||||||
|
- **Inline script:** Direct script code
|
||||||
|
- **File reference:** `@/path/to/script.lua` or `@./relative/path.lua`
|
||||||
|
- **URL reference:** `@http://...` or `@https://...`
|
||||||
|
- **Escaped `@`:** `@@...` for inline scripts that start with a literal `@`
|
||||||
|
|
||||||
|
**The `transform` function:**
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function transform(req)
|
||||||
|
-- req.method (string) - HTTP method (e.g. "GET", "POST")
|
||||||
|
-- req.path (string) - URL path (e.g. "/api/users")
|
||||||
|
-- req.body (string) - Request body
|
||||||
|
-- req.headers (table of string/arrays) - HTTP headers (e.g. {["X-Key"] = "value"})
|
||||||
|
-- req.params (table of string/arrays) - Query parameters (e.g. {["id"] = "123"})
|
||||||
|
-- req.cookies (table of string/arrays) - Cookies (e.g. {["session"] = "abc"})
|
||||||
|
|
||||||
|
req.headers["X-Custom"] = "my-value"
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Header, parameter, and cookie values can be a single string or a table (array) for multiple values per key (e.g. `{"val1", "val2"}`).
|
||||||
|
|
||||||
|
**YAML example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
lua: |
|
||||||
|
function transform(req)
|
||||||
|
req.headers["X-Custom"] = "my-value"
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
lua:
|
||||||
|
- "@/path/to/script1.lua"
|
||||||
|
- "@/path/to/script2.lua"
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
-lua 'function transform(req) req.headers["X-Custom"] = "my-value" return req end'
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
-lua @/path/to/script1.lua -lua @/path/to/script2.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
**ENV example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SARIN_LUA='function transform(req) req.headers["X-Custom"] = "my-value" return req end'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Js
|
||||||
|
|
||||||
|
JavaScript script(s) for request transformation. Each script must define a global `transform` function that receives a request object and returns the modified request object. Scripts run after template rendering, before the request is sent.
|
||||||
|
|
||||||
|
If multiple JavaScript scripts are provided, they are chained in order—the output of one becomes the input to the next. When both Lua and JavaScript scripts are specified, all Lua scripts run first, then all JavaScript scripts.
|
||||||
|
|
||||||
|
**Script sources:**
|
||||||
|
|
||||||
|
Scripts can be provided as:
|
||||||
|
|
||||||
|
- **Inline script:** Direct script code
|
||||||
|
- **File reference:** `@/path/to/script.js` or `@./relative/path.js`
|
||||||
|
- **URL reference:** `@http://...` or `@https://...`
|
||||||
|
- **Escaped `@`:** `@@...` for inline scripts that start with a literal `@`
|
||||||
|
|
||||||
|
**The `transform` function:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function transform(req) {
|
||||||
|
// req.method (string) - HTTP method (e.g. "GET", "POST")
|
||||||
|
// req.path (string) - URL path (e.g. "/api/users")
|
||||||
|
// req.body (string) - Request body
|
||||||
|
// req.headers (object of string/arrays) - HTTP headers (e.g. {"X-Key": "value"})
|
||||||
|
// req.params (object of string/arrays) - Query parameters (e.g. {"id": "123"})
|
||||||
|
// req.cookies (object of string/arrays) - Cookies (e.g. {"session": "abc"})
|
||||||
|
|
||||||
|
req.headers["X-Custom"] = "my-value";
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Header, parameter, and cookie values can be a single string or an array for multiple values per key (e.g. `["val1", "val2"]`).
|
||||||
|
|
||||||
|
**YAML example:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
js: |
|
||||||
|
function transform(req) {
|
||||||
|
req.headers["X-Custom"] = "my-value";
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
js:
|
||||||
|
- "@/path/to/script1.js"
|
||||||
|
- "@/path/to/script2.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
**CLI example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
-js 'function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
||||||
|
|
||||||
|
# OR
|
||||||
|
|
||||||
|
-js @/path/to/script1.js -js @/path/to/script2.js
|
||||||
|
```
|
||||||
|
|
||||||
|
**ENV example:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SARIN_JS='function transform(req) { req.headers["X-Custom"] = "my-value"; return req; }'
|
||||||
|
```
|
||||||
|
|||||||
312
docs/examples.md
312
docs/examples.md
@@ -9,11 +9,13 @@ This guide provides practical examples for common Sarin use cases.
|
|||||||
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
- [Headers, Cookies, and Parameters](#headers-cookies-and-parameters)
|
||||||
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
- [Dynamic Requests with Templating](#dynamic-requests-with-templating)
|
||||||
- [Request Bodies](#request-bodies)
|
- [Request Bodies](#request-bodies)
|
||||||
|
- [File Uploads](#file-uploads)
|
||||||
- [Using Proxies](#using-proxies)
|
- [Using Proxies](#using-proxies)
|
||||||
- [Output Formats](#output-formats)
|
- [Output Formats](#output-formats)
|
||||||
- [Docker Usage](#docker-usage)
|
- [Docker Usage](#docker-usage)
|
||||||
- [Dry Run Mode](#dry-run-mode)
|
- [Dry Run Mode](#dry-run-mode)
|
||||||
- [Show Configuration](#show-configuration)
|
- [Show Configuration](#show-configuration)
|
||||||
|
- [Scripting](#scripting)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -132,20 +134,34 @@ headers:
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
**Random headers from multiple values:**
|
**Multiple values for the same header (all sent in every request):**
|
||||||
|
|
||||||
> **Note:** When multiple values are provided for the same header, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point. This ensures all values are used while maintaining some randomness.
|
> **Note:** When the same key appears as separate entries (in CLI or config file), all values are sent in every request.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com -r 1000 -c 10 \
|
sarin -U http://example.com -r 1000 -c 10 \
|
||||||
-H "X-Region: us-east" \
|
-H "X-Region: us-east" \
|
||||||
-H "X-Region: us-west" \
|
-H "X-Region: us-west"
|
||||||
-H "X-Region: eu-central"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>YAML equivalent</summary>
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
headers:
|
||||||
|
- X-Region: us-east
|
||||||
|
- X-Region: us-west
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Cycling headers from multiple values (config file only):**
|
||||||
|
|
||||||
|
> **Note:** When multiple values are specified as an array on a single key, Sarin starts at a random index and cycles through all values in order. Once the cycle completes, it picks a new random starting point.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
url: http://example.com
|
url: http://example.com
|
||||||
requests: 1000
|
requests: 1000
|
||||||
@@ -157,8 +173,6 @@ headers:
|
|||||||
- eu-central
|
- eu-central
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Query parameters:**
|
**Query parameters:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -185,7 +199,7 @@ params:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com/users -r 1000 -c 10 \
|
sarin -U http://example.com/users -r 1000 -c 10 \
|
||||||
-P "id={{ fakeit_IntRange 1 1000 }}" \
|
-P "id={{ fakeit_Number 1 1000 }}" \
|
||||||
-P "fields=name,email"
|
-P "fields=name,email"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -197,7 +211,7 @@ url: http://example.com/users
|
|||||||
requests: 1000
|
requests: 1000
|
||||||
concurrency: 10
|
concurrency: 10
|
||||||
params:
|
params:
|
||||||
id: "{{ fakeit_IntRange 1 1000 }}"
|
id: "{{ fakeit_Number 1 1000 }}"
|
||||||
fields: "name,email"
|
fields: "name,email"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -456,7 +470,7 @@ body: |
|
|||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com/api/upload -r 1000 -c 10 \
|
sarin -U http://example.com/api/upload -r 1000 -c 10 \
|
||||||
-M POST \
|
-M POST \
|
||||||
-B '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}'
|
-B '{{ body_FormData "username" "john" "email" "john@example.com" }}'
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -467,7 +481,7 @@ url: http://example.com/api/upload
|
|||||||
requests: 1000
|
requests: 1000
|
||||||
concurrency: 10
|
concurrency: 10
|
||||||
method: POST
|
method: POST
|
||||||
body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com") }}'
|
body: '{{ body_FormData "username" "john" "email" "john@example.com" }}'
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
@@ -477,7 +491,7 @@ body: '{{ body_FormData (dict_Str "username" "john" "email" "john@example.com")
|
|||||||
```sh
|
```sh
|
||||||
sarin -U http://example.com/api/users -r 1000 -c 10 \
|
sarin -U http://example.com/api/users -r 1000 -c 10 \
|
||||||
-M POST \
|
-M POST \
|
||||||
-B '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}'
|
-B '{{ body_FormData "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone) }}'
|
||||||
```
|
```
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -488,13 +502,160 @@ url: http://example.com/api/users
|
|||||||
requests: 1000
|
requests: 1000
|
||||||
concurrency: 10
|
concurrency: 10
|
||||||
method: POST
|
method: POST
|
||||||
body: '{{ body_FormData (dict_Str "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone)) }}'
|
body: '{{ body_FormData "name" (fakeit_Name) "email" (fakeit_Email) "phone" (fakeit_Phone) }}'
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
> **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary.
|
> **Note:** `body_FormData` automatically sets the `Content-Type` header to `multipart/form-data` with the appropriate boundary.
|
||||||
|
|
||||||
|
## File Uploads
|
||||||
|
|
||||||
|
**File upload with multipart form data:**
|
||||||
|
|
||||||
|
Upload a local file:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/upload -r 100 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-B '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/upload
|
||||||
|
requests: 100
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
body: '{{ body_FormData "title" "My Document" "document" "@/path/to/file.pdf" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Multiple file uploads (same field name):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/upload -r 100 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-B '{{ body_FormData "files" "@/path/to/file1.pdf" "files" "@/path/to/file2.pdf" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/upload
|
||||||
|
requests: 100
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
body: |
|
||||||
|
{{ body_FormData
|
||||||
|
"files" "@/path/to/file1.pdf"
|
||||||
|
"files" "@/path/to/file2.pdf"
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Multiple file uploads (different field names):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/upload -r 100 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-B '{{ body_FormData "avatar" "@/path/to/photo.jpg" "resume" "@/path/to/cv.pdf" "cover_letter" "@/path/to/letter.docx" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/upload
|
||||||
|
requests: 100
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
body: |
|
||||||
|
{{ body_FormData
|
||||||
|
"avatar" "@/path/to/photo.jpg"
|
||||||
|
"resume" "@/path/to/cv.pdf"
|
||||||
|
"cover_letter" "@/path/to/letter.docx"
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**File from URL:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/upload -r 100 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-B '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/upload
|
||||||
|
requests: 100
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
> **Note:** Files (local and remote) are cached in memory after the first read, so they are not re-read for every request.
|
||||||
|
|
||||||
|
**Base64 encoded file in JSON body (local file):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/upload -r 100 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-B '{"file": "{{ file_Base64 "/path/to/file.pdf" }}", "filename": "document.pdf"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/upload
|
||||||
|
requests: 100
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
body: '{"file": "{{ file_Base64 "/path/to/file.pdf" }}", "filename": "document.pdf"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Base64 encoded file in JSON body (remote URL):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/upload -r 100 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-B '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}", "filename": "photo.jpg"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/upload
|
||||||
|
requests: 100
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}", "filename": "photo.jpg"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## Using Proxies
|
## Using Proxies
|
||||||
|
|
||||||
**Single HTTP proxy:**
|
**Single HTTP proxy:**
|
||||||
@@ -675,19 +836,19 @@ quiet: true
|
|||||||
**Basic Docker usage:**
|
**Basic Docker usage:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm aykhans/sarin -U http://example.com -r 1000 -c 10
|
docker run -it --rm aykhans/sarin -U http://example.com -r 1000 -c 10
|
||||||
```
|
```
|
||||||
|
|
||||||
**With local config file:**
|
**With local config file:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
|
docker run -it --rm -v $(pwd)/config.yaml:/config.yaml aykhans/sarin -f /config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**With remote config file:**
|
**With remote config file:**
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm aykhans/sarin -f https://example.com/config.yaml
|
docker run -it --rm aykhans/sarin -f https://example.com/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**Interactive mode with TTY:**
|
**Interactive mode with TTY:**
|
||||||
@@ -746,3 +907,124 @@ headers:
|
|||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Scripting
|
||||||
|
|
||||||
|
Transform requests using Lua or JavaScript scripts. Scripts run after template rendering, before the request is sent.
|
||||||
|
|
||||||
|
**Add a custom header with Lua:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-lua 'function transform(req) req.headers["X-Custom"] = "my-value" return req end'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
lua: |
|
||||||
|
function transform(req)
|
||||||
|
req.headers["X-Custom"] = "my-value"
|
||||||
|
return req
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Modify request body with JavaScript:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api/data -r 1000 -c 10 \
|
||||||
|
-M POST \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-B '{"name": "test"}' \
|
||||||
|
-js 'function transform(req) { var body = JSON.parse(req.body); body.timestamp = Date.now(); req.body = JSON.stringify(body); return req; }'
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api/data
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
method: POST
|
||||||
|
headers:
|
||||||
|
Content-Type: application/json
|
||||||
|
body: '{"name": "test"}'
|
||||||
|
js: |
|
||||||
|
function transform(req) {
|
||||||
|
var body = JSON.parse(req.body);
|
||||||
|
body.timestamp = Date.now();
|
||||||
|
req.body = JSON.stringify(body);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Load script from a file:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-lua @./scripts/transform.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
lua: "@./scripts/transform.lua"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Load script from a URL:**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-js @https://example.com/scripts/transform.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
js: "@https://example.com/scripts/transform.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
**Chain multiple scripts (Lua runs first, then JavaScript):**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sarin -U http://example.com/api -r 1000 -c 10 \
|
||||||
|
-lua @./scripts/auth.lua \
|
||||||
|
-lua @./scripts/headers.lua \
|
||||||
|
-js @./scripts/body.js
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>YAML equivalent</summary>
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
url: http://example.com/api
|
||||||
|
requests: 1000
|
||||||
|
concurrency: 10
|
||||||
|
lua:
|
||||||
|
- "@./scripts/auth.lua"
|
||||||
|
- "@./scripts/headers.lua"
|
||||||
|
js: "@./scripts/body.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ Sarin supports Go templates in URL paths, methods, bodies, headers, params, cook
|
|||||||
- [String Functions](#string-functions)
|
- [String Functions](#string-functions)
|
||||||
- [Collection Functions](#collection-functions)
|
- [Collection Functions](#collection-functions)
|
||||||
- [Body Functions](#body-functions)
|
- [Body Functions](#body-functions)
|
||||||
|
- [File Functions](#file-functions)
|
||||||
- [Fake Data Functions](#fake-data-functions)
|
- [Fake Data Functions](#fake-data-functions)
|
||||||
- [File](#file)
|
- [File](#file)
|
||||||
- [ID](#id)
|
- [ID](#id)
|
||||||
@@ -97,22 +98,76 @@ sarin -U http://example.com/users \
|
|||||||
| `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}` → `hello...` |
|
| `strings_Truncate(s string, n int)` | Truncate to `n` characters with ellipsis | `{{ strings_Truncate "hello world" 5 }}` → `hello...` |
|
||||||
| `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}` → `llo` |
|
| `strings_TrimPrefix(s string, prefix string)` | Remove prefix from string | `{{ strings_TrimPrefix "hello" "he" }}` → `llo` |
|
||||||
| `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}` → `hel` |
|
| `strings_TrimSuffix(s string, suffix string)` | Remove suffix from string | `{{ strings_TrimSuffix "hello" "lo" }}` → `hel` |
|
||||||
| `strings_Join(sep string, values ...string)` | Join strings with separator | `{{ strings_Join "-" "a" "b" "c" }}` → `a-b-c` |
|
|
||||||
|
|
||||||
### Collection Functions
|
### Collection Functions
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ----------------------------- | --------------------------------------------- | -------------------------------------------- |
|
| ---------------------------------------- | --------------------------------------------- | -------------------------------------------------------- |
|
||||||
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` |
|
| `dict_Str(pairs ...string)` | Create string dictionary from key-value pairs | `{{ dict_Str "key1" "val1" "key2" "val2" }}` |
|
||||||
| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
|
| `slice_Str(values ...string)` | Create string slice | `{{ slice_Str "a" "b" "c" }}` |
|
||||||
| `slice_Int(values ...int)` | Create int slice | `{{ slice_Int 1 2 3 }}` |
|
| `slice_Join(slice []string, sep string)` | Join string slice with separator | `{{ slice_Join (slice_Str "a" "b" "c") "-" }}` → `a-b-c` |
|
||||||
| `slice_Uint(values ...uint)` | Create uint slice | `{{ slice_Uint 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 }}` |
|
||||||
|
|
||||||
### Body Functions
|
### Body Functions
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------- |
|
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `body_FormData(fields map[string]string)` | Create multipart form data. Automatically sets the `Content-Type` header | `{{ body_FormData (dict_Str "field1" "value1") }}` |
|
| `body_FormData(pairs ...string)` | Create multipart form data from key-value pairs. Automatically sets the `Content-Type` header. Values starting with `@` are treated as file references (local path or URL). Use `@@` to escape literal `@`. | `{{ body_FormData "field1" "value1" "file" "@/path/to/file.pdf" }}` |
|
||||||
|
|
||||||
|
**`body_FormData` Details:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Text fields only
|
||||||
|
body: '{{ body_FormData "username" "john" "email" "john@example.com" }}'
|
||||||
|
|
||||||
|
# Single file upload
|
||||||
|
body: '{{ body_FormData "document" "@/path/to/file.pdf" }}'
|
||||||
|
|
||||||
|
# File from URL
|
||||||
|
body: '{{ body_FormData "image" "@https://example.com/photo.jpg" }}'
|
||||||
|
|
||||||
|
# Mixed text fields and files
|
||||||
|
body: |
|
||||||
|
{{ body_FormData
|
||||||
|
"title" "My Report"
|
||||||
|
"author" "John Doe"
|
||||||
|
"cover" "@/path/to/cover.jpg"
|
||||||
|
"document" "@/path/to/report.pdf"
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Multiple files with same field name
|
||||||
|
body: |
|
||||||
|
{{ body_FormData
|
||||||
|
"files" "@/path/to/file1.pdf"
|
||||||
|
"files" "@/path/to/file2.pdf"
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Escape @ for literal value (sends "@username")
|
||||||
|
body: '{{ body_FormData "twitter" "@@username" }}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** Files are cached in memory after the first read. Subsequent requests reuse the cached content, avoiding repeated disk/network I/O.
|
||||||
|
|
||||||
|
### File Functions
|
||||||
|
|
||||||
|
| Function | Description | Example |
|
||||||
|
| ---------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------- |
|
||||||
|
| `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` Details:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Local file as Base64 in JSON body
|
||||||
|
body: '{"file": "{{ file_Base64 "/path/to/document.pdf" }}", "filename": "document.pdf"}'
|
||||||
|
|
||||||
|
# Remote file as Base64
|
||||||
|
body: '{"image": "{{ file_Base64 "https://example.com/photo.jpg" }}"}'
|
||||||
|
|
||||||
|
# Combined with values for reuse
|
||||||
|
values: "FILE_DATA={{ file_Base64 \"/path/to/file.bin\" }}"
|
||||||
|
body: '{"data": "{{ .Values.FILE_DATA }}"}'
|
||||||
|
```
|
||||||
|
|
||||||
## Fake Data Functions
|
## Fake Data Functions
|
||||||
|
|
||||||
@@ -184,24 +239,24 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Address
|
### Address
|
||||||
|
|
||||||
| Function | Description | Example Output |
|
| Function | Description | Example Output |
|
||||||
| --------------------------------------------------- | ---------------------------- | --------------------------------------------------- |
|
| --------------------------------------------------- | ---------------------------- | ---------------------------------------------------- |
|
||||||
| `fakeit_City` | City name | `"Marcelside"` |
|
| `fakeit_City` | City name | `"Marcelside"` |
|
||||||
| `fakeit_Country` | Country name | `"United States of America"` |
|
| `fakeit_Country` | Country name | `"United States of America"` |
|
||||||
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
|
| `fakeit_CountryAbr` | Country abbreviation | `"US"` |
|
||||||
| `fakeit_State` | State name | `"Illinois"` |
|
| `fakeit_State` | State name | `"Illinois"` |
|
||||||
| `fakeit_StateAbr` | State abbreviation | `"IL"` |
|
| `fakeit_StateAbr` | State abbreviation | `"IL"` |
|
||||||
| `fakeit_Street` | Full street | `"364 East Rapidsborough"` |
|
| `fakeit_Street` | Full street | `"364 East Rapidsborough"` |
|
||||||
| `fakeit_StreetName` | Street name | `"View"` |
|
| `fakeit_StreetName` | Street name | `"View"` |
|
||||||
| `fakeit_StreetNumber` | Street number | `"13645"` |
|
| `fakeit_StreetNumber` | Street number | `"13645"` |
|
||||||
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
|
| `fakeit_StreetPrefix` | Street prefix | `"East"` |
|
||||||
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
|
| `fakeit_StreetSuffix` | Street suffix | `"Ave"` |
|
||||||
| `fakeit_Unit` | Unit | `"Apt 123"` |
|
| `fakeit_Unit` | Unit | `"Apt 123"` |
|
||||||
| `fakeit_Zip` | ZIP code | `"13645"` |
|
| `fakeit_Zip` | ZIP code | `"13645"` |
|
||||||
| `fakeit_Latitude` | Random latitude | `-73.534056` |
|
| `fakeit_Latitude` | Random latitude | `-73.534056` |
|
||||||
| `fakeit_Longitude` | Random longitude | `-147.068112` |
|
| `fakeit_Longitude` | Random longitude | `-147.068112` |
|
||||||
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` |
|
| `fakeit_LatitudeInRange(min float64, max float64)` | Latitude in specified range | `{{ fakeit_LatitudeInRange 0 90 }}` → `22.921026` |
|
||||||
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `-8.170450` |
|
| `fakeit_LongitudeInRange(min float64, max float64)` | Longitude in specified range | `{{ fakeit_LongitudeInRange 0 180 }}` → `122.471830` |
|
||||||
|
|
||||||
### Game
|
### Game
|
||||||
|
|
||||||
@@ -288,16 +343,16 @@ These functions are powered by [gofakeit](https://github.com/brianvoe/gofakeit)
|
|||||||
|
|
||||||
### Text
|
### Text
|
||||||
|
|
||||||
| Function | Description | Example |
|
| Function | Description | Example |
|
||||||
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
|
| ---------------------------------------------------------------------------------------- | ----------------------------------------------- | ----------------------------------------------------- |
|
||||||
| `fakeit_Sentence` | Random sentence | `{{ fakeit_Sentence }}` |
|
| `fakeit_Sentence(wordCount ...int)` | Random sentence (optional word count) | `{{ fakeit_Sentence }}` or `{{ fakeit_Sentence 10 }}` |
|
||||||
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
|
| `fakeit_Paragraph` | Random paragraph | `{{ fakeit_Paragraph }}` |
|
||||||
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
|
| `fakeit_LoremIpsumWord` | Lorem ipsum word | `"lorem"` |
|
||||||
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
| `fakeit_LoremIpsumSentence(wordCount int)` | Lorem ipsum sentence with specified word count | `{{ fakeit_LoremIpsumSentence 5 }}` |
|
||||||
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
| `fakeit_LoremIpsumParagraph(paragraphs int, sentences int, words int, separator string)` | Lorem ipsum paragraphs with specified structure | `{{ fakeit_LoremIpsumParagraph 1 3 5 "\n" }}` |
|
||||||
| `fakeit_Question` | Random question | `"What is your name?"` |
|
| `fakeit_Question` | Random question | `"What is your name?"` |
|
||||||
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
|
| `fakeit_Quote` | Random quote | `"Life is what happens..."` |
|
||||||
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
|
| `fakeit_Phrase` | Random phrase | `"a piece of cake"` |
|
||||||
|
|
||||||
### Foods
|
### Foods
|
||||||
|
|
||||||
|
|||||||
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")
|
||||||
|
}
|
||||||
24
go.mod
24
go.mod
@@ -1,19 +1,21 @@
|
|||||||
module go.aykhans.me/sarin
|
module go.aykhans.me/sarin
|
||||||
|
|
||||||
go 1.25.5
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0
|
github.com/brianvoe/gofakeit/v7 v7.14.0
|
||||||
github.com/charmbracelet/bubbles v0.21.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 v0.10.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-20260106131823-651366fbe6e3
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/valyala/fasthttp v1.69.0
|
github.com/valyala/fasthttp v1.69.0
|
||||||
|
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.3
|
go.yaml.in/yaml/v4 v4.0.0-rc.3
|
||||||
golang.org/x/net v0.49.0
|
golang.org/x/net v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -23,15 +25,17 @@ require (
|
|||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/charmbracelet/colorprofile v0.4.1 // 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.3 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f // indirect
|
||||||
github.com/clipperhouse/displaywidth v0.6.2 // indirect
|
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 // 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.3+incompatible // 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.2 // indirect
|
github.com/klauspost/compress v1.18.2 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
@@ -49,7 +53,7 @@ require (
|
|||||||
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.7.16 // 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.40.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/term v0.39.0 // indirect
|
golang.org/x/term v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
52
go.sum
52
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
||||||
|
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.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
github.com/alecthomas/chroma/v2 v2.21.1 h1:FaSDrp6N+3pphkNKU6HPCiYLgm8dbe5UXIXcoBhZSWA=
|
||||||
@@ -8,14 +10,14 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
|
|||||||
github.com/andybalholm/brotli v1.2.0/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.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
|
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.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
github.com/brianvoe/gofakeit/v7 v7.14.0 h1:R8tmT/rTDJmD2ngpqBL9rAKydiL7Qr2u3CXPqRt59pk=
|
||||||
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
github.com/brianvoe/gofakeit/v7 v7.14.0/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA=
|
||||||
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||||
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
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.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||||
@@ -26,28 +28,34 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG
|
|||||||
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=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3 h1:6DcVaqWI82BBVM/atTyq6yBoRLZFBsnoDoX9GCu2YOI=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
github.com/charmbracelet/x/ansi v0.11.3/go.mod h1:yI7Zslym9tCJcedxz5+WBq+eUGMJT0bM06Fqy1/Y4dI=
|
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
|
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
|
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-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f h1:kvAY8ffwhFuxWqtVI6+9E5vmgTApG96hswFLXJfsxHI=
|
||||||
github.com/charmbracelet/x/exp/slice v0.0.0-20260109001716-2fbdffcb221f/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.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
|
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||||
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
|
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
|
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||||
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
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-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM=
|
||||||
|
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.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
|
||||||
|
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||||
|
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
|
||||||
|
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=
|
||||||
@@ -95,21 +103,25 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
|||||||
github.com/yuin/goldmark v1.7.16/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.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
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.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.3/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.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
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/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=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -10,27 +10,13 @@ import (
|
|||||||
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
versionpkg "go.aykhans.me/sarin/internal/version"
|
versionpkg "go.aykhans.me/sarin/internal/version"
|
||||||
"go.aykhans.me/utils/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const cliUsageText = `Usage:
|
const cliUsageText = `Usage:
|
||||||
sarin [flags]
|
sarin [flags]
|
||||||
|
|
||||||
Simple usage:
|
Simple usage:
|
||||||
sarin -U https://example.com -d 1m
|
sarin -U https://example.com -r 1
|
||||||
|
|
||||||
Usage with all flags:
|
|
||||||
sarin -s -q -z -o json -f ./config.yaml -c 50 -r 100_000 -d 2m30s \
|
|
||||||
-U https://example.com \
|
|
||||||
-M POST \
|
|
||||||
-V "sharedUUID={{ fakeit_UUID }}" \
|
|
||||||
-B '{"product": "car"}' \
|
|
||||||
-P "id={{ .Values.sharedUUID }}" \
|
|
||||||
-H "User-Agent: {{ fakeit_UserAgent }}" -H "Accept: */*" \
|
|
||||||
-C "token={{ .Values.sharedUUID }}" \
|
|
||||||
-X "http://proxy.example.com" \
|
|
||||||
-T 3s \
|
|
||||||
-I
|
|
||||||
|
|
||||||
Flags:
|
Flags:
|
||||||
General Config:
|
General Config:
|
||||||
@@ -55,7 +41,9 @@ Flags:
|
|||||||
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
-X, -proxy []string Proxy for the request (e.g. "http://proxy.example.com:8080")
|
||||||
-V, -values []string List of values for templating (e.g. "key1=value1")
|
-V, -values []string List of values for templating (e.g. "key1=value1")
|
||||||
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
-T, -timeout time Timeout for the request (e.g. 400ms, 3s, 1m10s) (default %v)
|
||||||
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)`
|
-I, -insecure bool Skip SSL/TLS certificate verification (default %v)
|
||||||
|
-lua []string Lua script for request transformation (inline or @file/@url)
|
||||||
|
-js []string JavaScript script for request transformation (inline or @file/@url)`
|
||||||
|
|
||||||
var _ IParser = ConfigCLIParser{}
|
var _ IParser = ConfigCLIParser{}
|
||||||
|
|
||||||
@@ -83,7 +71,6 @@ func (arg *stringSliceArg) Set(value string) error {
|
|||||||
|
|
||||||
// Parse parses command-line arguments into a Config object.
|
// Parse parses command-line arguments into a Config object.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ErrCLINoArgs
|
|
||||||
// - types.CLIUnexpectedArgsError
|
// - types.CLIUnexpectedArgsError
|
||||||
// - types.FieldParseErrors
|
// - types.FieldParseErrors
|
||||||
func (parser ConfigCLIParser) Parse() (*Config, error) {
|
func (parser ConfigCLIParser) Parse() (*Config, error) {
|
||||||
@@ -106,16 +93,18 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
dryRun bool
|
dryRun bool
|
||||||
|
|
||||||
// Request config
|
// Request config
|
||||||
urlInput string
|
urlInput string
|
||||||
methods = stringSliceArg{}
|
methods = stringSliceArg{}
|
||||||
bodies = stringSliceArg{}
|
bodies = stringSliceArg{}
|
||||||
params = stringSliceArg{}
|
params = stringSliceArg{}
|
||||||
headers = stringSliceArg{}
|
headers = stringSliceArg{}
|
||||||
cookies = stringSliceArg{}
|
cookies = stringSliceArg{}
|
||||||
proxies = stringSliceArg{}
|
proxies = stringSliceArg{}
|
||||||
values = stringSliceArg{}
|
values = stringSliceArg{}
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
insecure bool
|
insecure bool
|
||||||
|
luaScripts = stringSliceArg{}
|
||||||
|
jsScripts = stringSliceArg{}
|
||||||
)
|
)
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -177,6 +166,10 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
|
|
||||||
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "insecure", false, "Skip SSL/TLS certificate verification")
|
||||||
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
flagSet.BoolVar(&insecure, "I", false, "Skip SSL/TLS certificate verification")
|
||||||
|
|
||||||
|
flagSet.Var(&luaScripts, "lua", "Lua script for request transformation (inline or @file/@url)")
|
||||||
|
|
||||||
|
flagSet.Var(&jsScripts, "js", "JavaScript script for request transformation (inline or @file/@url)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the specific arguments provided to the parser, skipping the program name.
|
// Parse the specific arguments provided to the parser, skipping the program name.
|
||||||
@@ -184,12 +177,6 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if no flags were set and no non-flag arguments were provided.
|
|
||||||
// This covers cases where `sarin` is run without any meaningful arguments.
|
|
||||||
if flagSet.NFlag() == 0 && len(flagSet.Args()) == 0 {
|
|
||||||
return nil, types.ErrCLINoArgs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for any unexpected non-flag arguments remaining after parsing.
|
// Check for any unexpected non-flag arguments remaining after parsing.
|
||||||
if args := flagSet.Args(); len(args) > 0 {
|
if args := flagSet.Args(); len(args) > 0 {
|
||||||
return nil, types.NewCLIUnexpectedArgsError(args)
|
return nil, types.NewCLIUnexpectedArgsError(args)
|
||||||
@@ -207,23 +194,23 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
switch flagVar.Name {
|
switch flagVar.Name {
|
||||||
// General config
|
// General config
|
||||||
case "show-config", "s":
|
case "show-config", "s":
|
||||||
config.ShowConfig = common.ToPtr(showConfig)
|
config.ShowConfig = new(showConfig)
|
||||||
case "config-file", "f":
|
case "config-file", "f":
|
||||||
for _, configFile := range configFiles {
|
for _, configFile := range configFiles {
|
||||||
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
||||||
}
|
}
|
||||||
case "concurrency", "c":
|
case "concurrency", "c":
|
||||||
config.Concurrency = common.ToPtr(concurrency)
|
config.Concurrency = new(concurrency)
|
||||||
case "requests", "r":
|
case "requests", "r":
|
||||||
config.Requests = common.ToPtr(requestCount)
|
config.Requests = new(requestCount)
|
||||||
case "duration", "d":
|
case "duration", "d":
|
||||||
config.Duration = common.ToPtr(duration)
|
config.Duration = new(duration)
|
||||||
case "quiet", "q":
|
case "quiet", "q":
|
||||||
config.Quiet = common.ToPtr(quiet)
|
config.Quiet = new(quiet)
|
||||||
case "output", "o":
|
case "output", "o":
|
||||||
config.Output = common.ToPtr(ConfigOutputType(output))
|
config.Output = new(ConfigOutputType(output))
|
||||||
case "dry-run", "z":
|
case "dry-run", "z":
|
||||||
config.DryRun = common.ToPtr(dryRun)
|
config.DryRun = new(dryRun)
|
||||||
|
|
||||||
// Request config
|
// Request config
|
||||||
case "url", "U":
|
case "url", "U":
|
||||||
@@ -256,9 +243,13 @@ func (parser ConfigCLIParser) Parse() (*Config, error) {
|
|||||||
case "values", "V":
|
case "values", "V":
|
||||||
config.Values = append(config.Values, values...)
|
config.Values = append(config.Values, values...)
|
||||||
case "timeout", "T":
|
case "timeout", "T":
|
||||||
config.Timeout = common.ToPtr(timeout)
|
config.Timeout = new(timeout)
|
||||||
case "insecure", "I":
|
case "insecure", "I":
|
||||||
config.Insecure = common.ToPtr(insecure)
|
config.Insecure = new(insecure)
|
||||||
|
case "lua":
|
||||||
|
config.Lua = append(config.Lua, luaScripts...)
|
||||||
|
case "js":
|
||||||
|
config.Js = append(config.Js, jsScripts...)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -16,6 +17,7 @@ import (
|
|||||||
"github.com/charmbracelet/glamour/styles"
|
"github.com/charmbracelet/glamour/styles"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
"github.com/charmbracelet/x/term"
|
"github.com/charmbracelet/x/term"
|
||||||
|
"go.aykhans.me/sarin/internal/script"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/sarin/internal/version"
|
"go.aykhans.me/sarin/internal/version"
|
||||||
"go.aykhans.me/utils/common"
|
"go.aykhans.me/utils/common"
|
||||||
@@ -87,10 +89,8 @@ type Config struct {
|
|||||||
Bodies []string `yaml:"bodies,omitempty"`
|
Bodies []string `yaml:"bodies,omitempty"`
|
||||||
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
Proxies types.Proxies `yaml:"proxies,omitempty"`
|
||||||
Values []string `yaml:"values,omitempty"`
|
Values []string `yaml:"values,omitempty"`
|
||||||
}
|
Lua []string `yaml:"lua,omitempty"`
|
||||||
|
Js []string `yaml:"js,omitempty"`
|
||||||
func NewConfig() *Config {
|
|
||||||
return &Config{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config Config) MarshalYAML() (any, error) {
|
func (config Config) MarshalYAML() (any, error) {
|
||||||
@@ -219,6 +219,8 @@ func (config Config) MarshalYAML() (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addStringSlice(content, "values", config.Values, false)
|
addStringSlice(content, "values", config.Values, false)
|
||||||
|
addStringSlice(content, "lua", config.Lua, false)
|
||||||
|
addStringSlice(content, "js", config.Js, false)
|
||||||
|
|
||||||
return root, nil
|
return root, nil
|
||||||
}
|
}
|
||||||
@@ -273,7 +275,7 @@ func (config Config) Print() bool {
|
|||||||
func (config *Config) Merge(newConfig *Config) {
|
func (config *Config) Merge(newConfig *Config) {
|
||||||
config.Files = append(config.Files, newConfig.Files...)
|
config.Files = append(config.Files, newConfig.Files...)
|
||||||
if len(newConfig.Methods) > 0 {
|
if len(newConfig.Methods) > 0 {
|
||||||
config.Methods = append(config.Methods, newConfig.Methods...)
|
config.Methods = newConfig.Methods
|
||||||
}
|
}
|
||||||
if newConfig.URL != nil {
|
if newConfig.URL != nil {
|
||||||
config.URL = newConfig.URL
|
config.URL = newConfig.URL
|
||||||
@@ -315,7 +317,7 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
config.Cookies = append(config.Cookies, newConfig.Cookies...)
|
config.Cookies = append(config.Cookies, newConfig.Cookies...)
|
||||||
}
|
}
|
||||||
if len(newConfig.Bodies) != 0 {
|
if len(newConfig.Bodies) != 0 {
|
||||||
config.Bodies = append(config.Bodies, newConfig.Bodies...)
|
config.Bodies = newConfig.Bodies
|
||||||
}
|
}
|
||||||
if len(newConfig.Proxies) != 0 {
|
if len(newConfig.Proxies) != 0 {
|
||||||
config.Proxies.Append(newConfig.Proxies...)
|
config.Proxies.Append(newConfig.Proxies...)
|
||||||
@@ -323,6 +325,12 @@ func (config *Config) Merge(newConfig *Config) {
|
|||||||
if len(newConfig.Values) != 0 {
|
if len(newConfig.Values) != 0 {
|
||||||
config.Values = append(config.Values, newConfig.Values...)
|
config.Values = append(config.Values, newConfig.Values...)
|
||||||
}
|
}
|
||||||
|
if len(newConfig.Lua) != 0 {
|
||||||
|
config.Lua = append(config.Lua, newConfig.Lua...)
|
||||||
|
}
|
||||||
|
if len(newConfig.Js) != 0 {
|
||||||
|
config.Js = append(config.Js, newConfig.Js...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (config *Config) SetDefaults() {
|
func (config *Config) SetDefaults() {
|
||||||
@@ -348,26 +356,26 @@ func (config *Config) SetDefaults() {
|
|||||||
config.Timeout = &Defaults.RequestTimeout
|
config.Timeout = &Defaults.RequestTimeout
|
||||||
}
|
}
|
||||||
if config.Concurrency == nil {
|
if config.Concurrency == nil {
|
||||||
config.Concurrency = common.ToPtr(Defaults.Concurrency)
|
config.Concurrency = new(Defaults.Concurrency)
|
||||||
}
|
}
|
||||||
if config.ShowConfig == nil {
|
if config.ShowConfig == nil {
|
||||||
config.ShowConfig = common.ToPtr(Defaults.ShowConfig)
|
config.ShowConfig = new(Defaults.ShowConfig)
|
||||||
}
|
}
|
||||||
if config.Quiet == nil {
|
if config.Quiet == nil {
|
||||||
config.Quiet = common.ToPtr(Defaults.Quiet)
|
config.Quiet = new(Defaults.Quiet)
|
||||||
}
|
}
|
||||||
if config.Insecure == nil {
|
if config.Insecure == nil {
|
||||||
config.Insecure = common.ToPtr(Defaults.Insecure)
|
config.Insecure = new(Defaults.Insecure)
|
||||||
}
|
}
|
||||||
if config.DryRun == nil {
|
if config.DryRun == nil {
|
||||||
config.DryRun = common.ToPtr(Defaults.DryRun)
|
config.DryRun = new(Defaults.DryRun)
|
||||||
}
|
}
|
||||||
if !config.Headers.Has("User-Agent") {
|
if !config.Headers.Has("User-Agent") {
|
||||||
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
|
config.Headers = append(config.Headers, types.Header{Key: "User-Agent", Value: []string{Defaults.UserAgent}})
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Output == nil {
|
if config.Output == nil {
|
||||||
config.Output = common.ToPtr(Defaults.Output)
|
config.Output = new(Defaults.Output)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +473,44 @@ func (config Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a context with timeout for script validation (loading from URLs)
|
||||||
|
scriptCtx, scriptCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer scriptCancel()
|
||||||
|
|
||||||
|
for i, scriptSrc := range config.Lua {
|
||||||
|
if err := validateScriptSource(scriptSrc); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Validate script syntax
|
||||||
|
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeLua); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Lua[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, scriptSrc := range config.Js {
|
||||||
|
if err := validateScriptSource(scriptSrc); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Validate script syntax
|
||||||
|
if err := script.ValidateScript(scriptCtx, scriptSrc, script.EngineTypeJavaScript); err != nil {
|
||||||
|
validationErrors = append(
|
||||||
|
validationErrors,
|
||||||
|
types.NewFieldValidationError(fmt.Sprintf("Js[%d]", i), scriptSrc, err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
templateErrors := ValidateTemplates(&config)
|
templateErrors := ValidateTemplates(&config)
|
||||||
validationErrors = append(validationErrors, templateErrors...)
|
validationErrors = append(validationErrors, templateErrors...)
|
||||||
|
|
||||||
@@ -490,12 +536,6 @@ func ReadAllConfigs() *Config {
|
|||||||
cliParser := NewConfigCLIParser(os.Args)
|
cliParser := NewConfigCLIParser(os.Args)
|
||||||
cliConf, err := cliParser.Parse()
|
cliConf, err := cliParser.Parse()
|
||||||
_ = utilsErr.MustHandle(err,
|
_ = utilsErr.MustHandle(err,
|
||||||
utilsErr.OnSentinel(types.ErrCLINoArgs, func(err error) error {
|
|
||||||
cliParser.PrintHelp()
|
|
||||||
fmt.Fprintln(os.Stderr, StyleYellow.Render("\nNo arguments provided."))
|
|
||||||
os.Exit(1)
|
|
||||||
return nil
|
|
||||||
}),
|
|
||||||
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
utilsErr.OnType(func(err types.CLIUnexpectedArgsError) error {
|
||||||
cliParser.PrintHelp()
|
cliParser.PrintHelp()
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprintln(os.Stderr,
|
||||||
@@ -582,6 +622,57 @@ func parseConfigFile(configFile types.ConfigFile, maxDepth int) (*Config, error)
|
|||||||
return fileConfig, nil
|
return fileConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateScriptSource validates a script source string.
|
||||||
|
// Scripts can be:
|
||||||
|
// - Inline script: any string not starting with "@"
|
||||||
|
// - Escaped "@": strings starting with "@@" (literal "@" at start)
|
||||||
|
// - File reference: "@/path/to/file" or "@./relative/path"
|
||||||
|
// - URL reference: "@http://..." or "@https://..."
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ErrScriptSourceEmpty
|
||||||
|
// - types.ErrScriptURLNoHost
|
||||||
|
// - types.URLParseError
|
||||||
|
func validateScriptSource(script string) error {
|
||||||
|
// Empty script is invalid
|
||||||
|
if script == "" {
|
||||||
|
return types.ErrScriptEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a file/URL reference - it's an inline script
|
||||||
|
if !strings.HasPrefix(script, "@") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escaped @ - it's an inline script starting with literal @
|
||||||
|
if strings.HasPrefix(script, "@@") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a file or URL reference - validate the source
|
||||||
|
source := script[1:] // Remove the @ prefix
|
||||||
|
|
||||||
|
if source == "" {
|
||||||
|
return types.ErrScriptSourceEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a http(s) URL
|
||||||
|
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
|
||||||
|
parsedURL, err := url.Parse(source)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewURLParseError(source, err)
|
||||||
|
}
|
||||||
|
if parsedURL.Host == "" {
|
||||||
|
return types.ErrScriptURLNoHost
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// It's a file path - basic validation (not empty, checked above)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
func printParseErrors(parserName string, errors ...types.FieldParseError) {
|
||||||
for _, fieldErr := range errors {
|
for _, fieldErr := range errors {
|
||||||
if fieldErr.Value == "" {
|
if fieldErr.Value == "" {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/utils/common"
|
|
||||||
utilsParse "go.aykhans.me/utils/parser"
|
utilsParse "go.aykhans.me/utils/parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,7 +66,7 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if output := parser.getEnv("OUTPUT"); output != "" {
|
if output := parser.getEnv("OUTPUT"); output != "" {
|
||||||
config.Output = common.ToPtr(ConfigOutputType(output))
|
config.Output = new(ConfigOutputType(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
if insecure := parser.getEnv("INSECURE"); insecure != "" {
|
||||||
@@ -216,6 +215,14 @@ func (parser ConfigENVParser) Parse() (*Config, error) {
|
|||||||
config.Values = []string{values}
|
config.Values = []string{values}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if lua := parser.getEnv("LUA"); lua != "" {
|
||||||
|
config.Lua = []string{lua}
|
||||||
|
}
|
||||||
|
|
||||||
|
if js := parser.getEnv("JS"); js != "" {
|
||||||
|
config.Js = []string{js}
|
||||||
|
}
|
||||||
|
|
||||||
if len(fieldParseErrors) > 0 {
|
if len(fieldParseErrors) > 0 {
|
||||||
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
"go.aykhans.me/utils/common"
|
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +48,10 @@ func (parser ConfigFileParser) Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
|
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
||||||
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
|
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
|
||||||
return fetchHTTP(ctx, src)
|
return fetchHTTP(ctx, src)
|
||||||
@@ -57,25 +60,28 @@ func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
|
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to fetch file: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close() //nolint:errcheck
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, fmt.Errorf("failed to fetch file: HTTP %d %s", resp.StatusCode, resp.Status)
|
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(resp.Body)
|
data, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
return nil, types.NewHTTPFetchError(url, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
@@ -83,19 +89,21 @@ func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
|||||||
|
|
||||||
// fetchLocal reads file contents from the local filesystem.
|
// fetchLocal reads file contents from the local filesystem.
|
||||||
// It resolves relative paths from the current working directory.
|
// It resolves relative paths from the current working directory.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
func fetchLocal(src string) ([]byte, error) {
|
func fetchLocal(src string) ([]byte, error) {
|
||||||
path := src
|
path := src
|
||||||
if !filepath.IsAbs(src) {
|
if !filepath.IsAbs(src) {
|
||||||
pwd, err := os.Getwd()
|
pwd, err := os.Getwd()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get working directory: %w", err)
|
return nil, types.NewFileReadError(src, err)
|
||||||
}
|
}
|
||||||
path = filepath.Join(pwd, src)
|
path = filepath.Join(pwd, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path) //nolint:gosec
|
data, err := os.ReadFile(path) //nolint:gosec
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
return nil, types.NewFileReadError(path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
@@ -202,6 +210,8 @@ type configYAML struct {
|
|||||||
Bodies stringOrSliceField `yaml:"body"`
|
Bodies stringOrSliceField `yaml:"body"`
|
||||||
Proxies stringOrSliceField `yaml:"proxy"`
|
Proxies stringOrSliceField `yaml:"proxy"`
|
||||||
Values stringOrSliceField `yaml:"values"`
|
Values stringOrSliceField `yaml:"values"`
|
||||||
|
Lua stringOrSliceField `yaml:"lua"`
|
||||||
|
Js stringOrSliceField `yaml:"js"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseYAML parses YAML config file arguments into a Config object.
|
// ParseYAML parses YAML config file arguments into a Config object.
|
||||||
@@ -230,7 +240,7 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
config.Quiet = parsedData.Quiet
|
config.Quiet = parsedData.Quiet
|
||||||
|
|
||||||
if parsedData.Output != nil {
|
if parsedData.Output != nil {
|
||||||
config.Output = common.ToPtr(ConfigOutputType(*parsedData.Output))
|
config.Output = new(ConfigOutputType(*parsedData.Output))
|
||||||
}
|
}
|
||||||
|
|
||||||
config.Insecure = parsedData.Insecure
|
config.Insecure = parsedData.Insecure
|
||||||
@@ -246,6 +256,8 @@ func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|||||||
}
|
}
|
||||||
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
||||||
config.Values = append(config.Values, parsedData.Values...)
|
config.Values = append(config.Values, parsedData.Values...)
|
||||||
|
config.Lua = append(config.Lua, parsedData.Lua...)
|
||||||
|
config.Js = append(config.Js, parsedData.Js...)
|
||||||
|
|
||||||
if len(parsedData.ConfigFiles) > 0 {
|
if len(parsedData.ConfigFiles) > 0 {
|
||||||
for _, configFile := range parsedData.ConfigFiles {
|
for _, configFile := range parsedData.ConfigFiles {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.TemplateParseError
|
||||||
func validateTemplateString(value string, funcMap template.FuncMap) error {
|
func validateTemplateString(value string, funcMap template.FuncMap) error {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return nil
|
return nil
|
||||||
@@ -15,7 +17,7 @@ func validateTemplateString(value string, funcMap template.FuncMap) error {
|
|||||||
|
|
||||||
_, err := template.New("").Funcs(funcMap).Parse(value)
|
_, err := template.New("").Funcs(funcMap).Parse(value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("template parse error: %w", err)
|
return types.NewTemplateParseError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -191,11 +193,12 @@ func validateTemplateURLPath(urlPath string, funcMap template.FuncMap) []types.F
|
|||||||
|
|
||||||
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
func ValidateTemplates(config *Config) []types.FieldValidationError {
|
||||||
// Create template function map using the same functions as sarin package
|
// Create template function map using the same functions as sarin package
|
||||||
|
// Use nil for fileCache during validation - templates are only parsed, not executed
|
||||||
randSource := sarin.NewDefaultRandSource()
|
randSource := sarin.NewDefaultRandSource()
|
||||||
funcMap := sarin.NewDefaultTemplateFuncMap(randSource)
|
funcMap := sarin.NewDefaultTemplateFuncMap(randSource, nil)
|
||||||
|
|
||||||
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
|
bodyFuncMapData := &sarin.BodyTemplateFuncMapData{}
|
||||||
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData)
|
bodyFuncMap := sarin.NewDefaultBodyTemplateFuncMap(randSource, bodyFuncMapData, nil)
|
||||||
|
|
||||||
var allErrors []types.FieldValidationError
|
var allErrors []types.FieldValidationError
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
|
||||||
"math"
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -95,6 +94,9 @@ func NewHostClients(
|
|||||||
return []*fasthttp.HostClient{client}, nil
|
return []*fasthttp.HostClient{client}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewProxyDialFunc creates a dial function for the given proxy URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ProxyUnsupportedSchemeError
|
||||||
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Duration) (fasthttp.DialFunc, error) {
|
||||||
var (
|
var (
|
||||||
dialer fasthttp.DialFunc
|
dialer fasthttp.DialFunc
|
||||||
@@ -117,16 +119,14 @@ func NewProxyDialFunc(ctx context.Context, proxyURL *url.URL, timeout time.Durat
|
|||||||
case "https":
|
case "https":
|
||||||
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
dialer = fasthttpHTTPSDialerDualStackTimeout(proxyURL, timeout)
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("unsupported proxy scheme")
|
return nil, types.NewProxyUnsupportedSchemeError(proxyURL.Scheme)
|
||||||
}
|
|
||||||
|
|
||||||
if dialer == nil {
|
|
||||||
return nil, errors.New("internal error: proxy dialer is nil")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return dialer, nil
|
return dialer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The returned dial function can return the following errors:
|
||||||
|
// - types.ProxyDialError
|
||||||
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL, timeout time.Duration, resolveLocally bool) (fasthttp.DialFunc, error) {
|
||||||
netDialer := &net.Dialer{}
|
netDialer := &net.Dialer{}
|
||||||
|
|
||||||
@@ -147,12 +147,18 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
// Assert to ContextDialer for timeout support
|
// Assert to ContextDialer for timeout support
|
||||||
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
contextDialer, ok := socksDialer.(proxy.ContextDialer)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Fallback without timeout (should not happen with net.Dialer)
|
// Fallback without timeout (should not happen with net.Dialer)
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string) (net.Conn, error) {
|
||||||
return socksDialer.Dial("tcp", addr)
|
conn, err := socksDialer.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +169,7 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
if resolveLocally {
|
if resolveLocally {
|
||||||
host, port, err := net.SplitHostPort(addr)
|
host, port, err := net.SplitHostPort(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap DNS resolution to half the timeout to reserve time for dial
|
// Cap DNS resolution to half the timeout to reserve time for dial
|
||||||
@@ -171,10 +177,10 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
ips, err := net.DefaultResolver.LookupIP(dnsCtx, "ip", host)
|
||||||
dnsCancel()
|
dnsCancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
return nil, errors.New("no IP addresses found for host: " + host)
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyResolveError(host))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the first resolved IP
|
// Use the first resolved IP
|
||||||
@@ -184,16 +190,22 @@ func fasthttpSocksDialerDualStackTimeout(ctx context.Context, proxyURL *url.URL,
|
|||||||
// Use remaining time for dial
|
// Use remaining time for dial
|
||||||
remaining := time.Until(deadline)
|
remaining := time.Until(deadline)
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
return nil, context.DeadlineExceeded
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
dialCtx, dialCancel := context.WithTimeout(ctx, remaining)
|
||||||
defer dialCancel()
|
defer dialCancel()
|
||||||
|
|
||||||
return contextDialer.DialContext(dialCtx, "tcp", addr)
|
conn, err := contextDialer.DialContext(dialCtx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The returned dial function can return the following errors:
|
||||||
|
// - types.ProxyDialError
|
||||||
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duration) fasthttp.DialFunc {
|
||||||
proxyAddr := proxyURL.Host
|
proxyAddr := proxyURL.Host
|
||||||
if proxyURL.Port() == "" {
|
if proxyURL.Port() == "" {
|
||||||
@@ -209,24 +221,26 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
proxyAuth = "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxyStr := proxyURL.String()
|
||||||
|
|
||||||
return func(addr string) (net.Conn, error) {
|
return func(addr string) (net.Conn, error) {
|
||||||
// Establish TCP connection to proxy with timeout
|
// Establish TCP connection to proxy with timeout
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
conn, err := fasthttp.DialDualStackTimeout(proxyAddr, timeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := timeout - time.Since(start)
|
remaining := timeout - time.Since(start)
|
||||||
if remaining <= 0 {
|
if remaining <= 0 {
|
||||||
conn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, context.DeadlineExceeded
|
return nil, types.NewProxyDialError(proxyStr, context.DeadlineExceeded)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set deadline for the TLS handshake and CONNECT request
|
// Set deadline for the TLS handshake and CONNECT request
|
||||||
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
|
if err := conn.SetDeadline(time.Now().Add(remaining)); err != nil {
|
||||||
conn.Close() //nolint:errcheck,gosec
|
conn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to TLS
|
// Upgrade to TLS
|
||||||
@@ -235,7 +249,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
})
|
})
|
||||||
if err := tlsConn.Handshake(); err != nil {
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build and send CONNECT request
|
// Build and send CONNECT request
|
||||||
@@ -251,7 +265,7 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
|
|
||||||
if err := connectReq.Write(tlsConn); err != nil {
|
if err := connectReq.Write(tlsConn); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response using buffered reader, but return wrapped connection
|
// Read response using buffered reader, but return wrapped connection
|
||||||
@@ -260,19 +274,19 @@ func fasthttpHTTPSDialerDualStackTimeout(proxyURL *url.URL, timeout time.Duratio
|
|||||||
resp, err := http.ReadResponse(bufReader, connectReq)
|
resp, err := http.ReadResponse(bufReader, connectReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
resp.Body.Close() //nolint:errcheck,gosec
|
resp.Body.Close() //nolint:errcheck,gosec
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, errors.New("proxy CONNECT failed: " + resp.Status)
|
return nil, types.NewProxyDialError(proxyStr, types.NewProxyConnectError(resp.Status))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear deadline for the tunneled connection
|
// Clear deadline for the tunneled connection
|
||||||
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
if err := tlsConn.SetDeadline(time.Time{}); err != nil {
|
||||||
tlsConn.Close() //nolint:errcheck,gosec
|
tlsConn.Close() //nolint:errcheck,gosec
|
||||||
return nil, err
|
return nil, types.NewProxyDialError(proxyStr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return wrapped connection that uses the buffered reader
|
// Return wrapped connection that uses the buffered reader
|
||||||
|
|||||||
114
internal/sarin/filecache.go
Normal file
114
internal/sarin/filecache.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package sarin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CachedFile holds the cached content and metadata of a file.
|
||||||
|
type CachedFile struct {
|
||||||
|
Content []byte
|
||||||
|
Filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileCache struct {
|
||||||
|
cache sync.Map // map[string]*CachedFile
|
||||||
|
requestTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileCache(requestTimeout time.Duration) *FileCache {
|
||||||
|
return &FileCache{
|
||||||
|
requestTimeout: requestTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrLoad retrieves a file from cache or loads it using the provided source.
|
||||||
|
// The source can be a local file path or an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
|
func (fc *FileCache) GetOrLoad(source string) (*CachedFile, error) {
|
||||||
|
if val, ok := fc.cache.Load(source); ok {
|
||||||
|
return val.(*CachedFile), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
content []byte
|
||||||
|
filename string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if strings.HasPrefix(source, "http://") || strings.HasPrefix(source, "https://") {
|
||||||
|
content, filename, err = fc.fetchURL(source)
|
||||||
|
} else {
|
||||||
|
content, filename, err = fc.readLocalFile(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
file := &CachedFile{Content: content, Filename: filename}
|
||||||
|
|
||||||
|
// LoadOrStore handles race condition - if another goroutine
|
||||||
|
// cached it first, we get theirs (no duplicate storage)
|
||||||
|
actual, _ := fc.cache.LoadOrStore(source, file)
|
||||||
|
return actual.(*CachedFile), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLocalFile reads a file from the local filesystem and returns its content and filename.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
func (fc *FileCache) readLocalFile(filePath string) ([]byte, string, error) {
|
||||||
|
content, err := os.ReadFile(filePath) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", types.NewFileReadError(filePath, err)
|
||||||
|
}
|
||||||
|
return content, filepath.Base(filePath), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchURL downloads file contents from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
|
func (fc *FileCache) fetchURL(url string) ([]byte, string, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: fc.requestTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract filename from URL path
|
||||||
|
filename := path.Base(url)
|
||||||
|
if filename == "" || filename == "/" || filename == "." {
|
||||||
|
filename = "downloaded_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove query string from filename if present
|
||||||
|
if idx := strings.Index(filename, "?"); idx != -1 {
|
||||||
|
filename = filename[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
return content, filename, nil
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"maps"
|
"maps"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -11,13 +10,14 @@ import (
|
|||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
|
"go.aykhans.me/sarin/internal/script"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
utilsSlice "go.aykhans.me/utils/slice"
|
utilsSlice "go.aykhans.me/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
type RequestGenerator func(*fasthttp.Request) error
|
type RequestGenerator func(*fasthttp.Request) error
|
||||||
|
|
||||||
type RequestGeneratorWithData func(*fasthttp.Request, any) error
|
type requestDataGenerator func(*script.RequestData, any) error
|
||||||
|
|
||||||
type valuesData struct {
|
type valuesData struct {
|
||||||
Values map[string]string
|
Values map[string]string
|
||||||
@@ -26,6 +26,9 @@ type valuesData struct {
|
|||||||
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
|
// NewRequestGenerator creates a new RequestGenerator function that generates HTTP requests
|
||||||
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
|
// with the specified configuration. The returned RequestGenerator is NOT safe for concurrent
|
||||||
// use by multiple goroutines.
|
// use by multiple goroutines.
|
||||||
|
//
|
||||||
|
// Note: Scripts must be validated before calling this function (e.g., in NewSarin).
|
||||||
|
// The caller is responsible for managing the scriptTransformer lifecycle.
|
||||||
func NewRequestGenerator(
|
func NewRequestGenerator(
|
||||||
methods []string,
|
methods []string,
|
||||||
requestURL *url.URL,
|
requestURL *url.URL,
|
||||||
@@ -34,11 +37,13 @@ func NewRequestGenerator(
|
|||||||
cookies types.Cookies,
|
cookies types.Cookies,
|
||||||
bodies []string,
|
bodies []string,
|
||||||
values []string,
|
values []string,
|
||||||
|
fileCache *FileCache,
|
||||||
|
scriptTransformer *script.Transformer,
|
||||||
) (RequestGenerator, bool) {
|
) (RequestGenerator, bool) {
|
||||||
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)
|
templateFuncMap := NewDefaultTemplateFuncMap(randSource, fileCache)
|
||||||
|
|
||||||
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
|
pathGenerator, isPathGeneratorDynamic := createTemplateFunc(requestURL.Path, templateFuncMap)
|
||||||
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
|
methodGenerator, isMethodGeneratorDynamic := NewMethodGeneratorFunc(localRand, methods, templateFuncMap)
|
||||||
@@ -47,18 +52,29 @@ func NewRequestGenerator(
|
|||||||
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
|
cookiesGenerator, isCookiesGeneratorDynamic := NewCookiesGeneratorFunc(localRand, cookies, templateFuncMap)
|
||||||
|
|
||||||
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
bodyTemplateFuncMapData := &BodyTemplateFuncMapData{}
|
||||||
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData)
|
bodyTemplateFuncMap := NewDefaultBodyTemplateFuncMap(randSource, bodyTemplateFuncMapData, fileCache)
|
||||||
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
|
bodyGenerator, isBodyGeneratorDynamic := NewBodyGeneratorFunc(localRand, bodies, bodyTemplateFuncMap)
|
||||||
|
|
||||||
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
|
valuesGenerator := NewValuesGeneratorFunc(values, templateFuncMap)
|
||||||
|
|
||||||
|
hasScripts := scriptTransformer != nil && !scriptTransformer.IsEmpty()
|
||||||
|
|
||||||
|
host := requestURL.Host
|
||||||
|
scheme := requestURL.Scheme
|
||||||
|
|
||||||
|
reqData := &script.RequestData{
|
||||||
|
Headers: make(map[string][]string),
|
||||||
|
Params: make(map[string][]string),
|
||||||
|
Cookies: make(map[string][]string),
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
data valuesData
|
data valuesData
|
||||||
path string
|
path string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request) error {
|
return func(req *fasthttp.Request) error {
|
||||||
req.Header.SetHost(requestURL.Host)
|
resetRequestData(reqData)
|
||||||
|
|
||||||
data, err = valuesGenerator()
|
data, err = valuesGenerator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,87 +85,135 @@ func NewRequestGenerator(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.SetRequestURI(path)
|
reqData.Path = path
|
||||||
|
|
||||||
if err = methodGenerator(req, data); err != nil {
|
if err = methodGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyTemplateFuncMapData.ClearFormDataContenType()
|
bodyTemplateFuncMapData.ClearFormDataContenType()
|
||||||
if err = bodyGenerator(req, data); err != nil {
|
if err = bodyGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = headersGenerator(req, data); err != nil {
|
if err = headersGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
|
if bodyTemplateFuncMapData.GetFormDataContenType() != "" {
|
||||||
req.Header.Add("Content-Type", bodyTemplateFuncMapData.GetFormDataContenType())
|
reqData.Headers["Content-Type"] = append(reqData.Headers["Content-Type"], bodyTemplateFuncMapData.GetFormDataContenType())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = paramsGenerator(req, data); err != nil {
|
if err = paramsGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err = cookiesGenerator(req, data); err != nil {
|
if err = cookiesGenerator(reqData, data); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestURL.Scheme == "https" {
|
if hasScripts {
|
||||||
req.URI().SetScheme("https")
|
if err = scriptTransformer.Transform(reqData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyRequestDataToFastHTTP(reqData, req, host, scheme)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}, isPathGeneratorDynamic ||
|
}, isPathGeneratorDynamic ||
|
||||||
isMethodGeneratorDynamic ||
|
isMethodGeneratorDynamic ||
|
||||||
isParamsGeneratorDynamic ||
|
isParamsGeneratorDynamic ||
|
||||||
isHeadersGeneratorDynamic ||
|
isHeadersGeneratorDynamic ||
|
||||||
isCookiesGeneratorDynamic ||
|
isCookiesGeneratorDynamic ||
|
||||||
isBodyGeneratorDynamic
|
isBodyGeneratorDynamic ||
|
||||||
|
hasScripts
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func resetRequestData(reqData *script.RequestData) {
|
||||||
|
reqData.Method = ""
|
||||||
|
reqData.Path = ""
|
||||||
|
reqData.Body = ""
|
||||||
|
clear(reqData.Headers)
|
||||||
|
clear(reqData.Params)
|
||||||
|
clear(reqData.Cookies)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyRequestDataToFastHTTP(reqData *script.RequestData, req *fasthttp.Request, host, scheme string) {
|
||||||
|
req.Header.SetHost(host)
|
||||||
|
req.SetRequestURI(reqData.Path)
|
||||||
|
req.Header.SetMethod(reqData.Method)
|
||||||
|
req.SetBody([]byte(reqData.Body))
|
||||||
|
|
||||||
|
for k, values := range reqData.Headers {
|
||||||
|
for _, v := range values {
|
||||||
|
req.Header.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, values := range reqData.Params {
|
||||||
|
for _, v := range values {
|
||||||
|
req.URI().QueryArgs().Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(reqData.Cookies) > 0 {
|
||||||
|
cookieStrings := make([]string, 0, len(reqData.Cookies))
|
||||||
|
for k, values := range reqData.Cookies {
|
||||||
|
for _, v := range values {
|
||||||
|
cookieStrings = append(cookieStrings, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if scheme == "https" {
|
||||||
|
req.URI().SetScheme("https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMethodGeneratorFunc(localRand *rand.Rand, methods []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
|
methodGenerator, isDynamic := buildStringSliceGenerator(localRand, methods, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
method string
|
method string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
method, err = methodGenerator()(data)
|
method, err = methodGenerator()(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.SetMethod(method)
|
reqData.Method = method
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewBodyGeneratorFunc(localRand *rand.Rand, bodies []string, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
|
bodyGenerator, isDynamic := buildStringSliceGenerator(localRand, bodies, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
body string
|
body string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
body, err = bodyGenerator()(data)
|
body, err = bodyGenerator()(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.SetBody([]byte(body))
|
reqData.Body = body
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, params, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
for _, gen := range generators {
|
for _, gen := range generators {
|
||||||
key, err = gen.Key(data)
|
key, err = gen.Key(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -161,20 +225,20 @@ func NewParamsGeneratorFunc(localRand *rand.Rand, params types.Params, templateF
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.URI().QueryArgs().Add(key, value)
|
reqData.Params[key] = append(reqData.Params[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, headers, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
return func(req *fasthttp.Request, data any) error {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
for _, gen := range generators {
|
for _, gen := range generators {
|
||||||
key, err = gen.Key(data)
|
key, err = gen.Key(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,41 +250,33 @@ func NewHeadersGeneratorFunc(localRand *rand.Rand, headers types.Headers, templa
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Add(key, value)
|
reqData.Headers[key] = append(reqData.Headers[key], value)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (RequestGeneratorWithData, bool) {
|
func NewCookiesGeneratorFunc(localRand *rand.Rand, cookies types.Cookies, templateFunctions template.FuncMap) (requestDataGenerator, bool) {
|
||||||
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
|
generators, isDynamic := buildKeyValueGenerators(localRand, cookies, templateFunctions)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
key, value string
|
key, value string
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if len(generators) > 0 {
|
return func(reqData *script.RequestData, data any) error {
|
||||||
return func(req *fasthttp.Request, data any) error {
|
for _, gen := range generators {
|
||||||
cookieStrings := make([]string, 0, len(generators))
|
key, err = gen.Key(data)
|
||||||
for _, gen := range generators {
|
if err != nil {
|
||||||
key, err = gen.Key(data)
|
return err
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err = gen.Value()(data)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cookieStrings = append(cookieStrings, key+"="+value)
|
|
||||||
}
|
}
|
||||||
req.Header.Add("Cookie", strings.Join(cookieStrings, "; "))
|
|
||||||
return nil
|
|
||||||
}, isDynamic
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(req *fasthttp.Request, data any) error {
|
value, err = gen.Value()(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
reqData.Cookies[key] = append(reqData.Cookies[key], value)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}, isDynamic
|
}, isDynamic
|
||||||
}
|
}
|
||||||
@@ -242,12 +298,12 @@ func NewValuesGeneratorFunc(values []string, templateFunctions template.FuncMap)
|
|||||||
for _, generator := range generators {
|
for _, generator := range generators {
|
||||||
rendered, err = generator(nil)
|
rendered, err = generator(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return valuesData{}, fmt.Errorf("values rendering: %w", err)
|
return valuesData{}, types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err = godotenv.Unmarshal(rendered)
|
data, err = godotenv.Unmarshal(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return valuesData{}, fmt.Errorf("values rendering: %w", err)
|
return valuesData{}, types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
maps.Copy(result, data)
|
maps.Copy(result, data)
|
||||||
@@ -264,7 +320,7 @@ func createTemplateFunc(value string, templateFunctions template.FuncMap) (func(
|
|||||||
return func(data any) (string, error) {
|
return func(data any) (string, error) {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err = tmpl.Execute(&buf, data); err != nil {
|
if err = tmpl.Execute(&buf, data); err != nil {
|
||||||
return "", fmt.Errorf("template rendering: %w", err)
|
return "", types.NewTemplateRenderError(err)
|
||||||
}
|
}
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}, true
|
}, true
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sarin
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -13,7 +14,9 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/term"
|
||||||
"github.com/valyala/fasthttp"
|
"github.com/valyala/fasthttp"
|
||||||
|
"go.aykhans.me/sarin/internal/script"
|
||||||
"go.aykhans.me/sarin/internal/types"
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,11 +54,15 @@ type sarin struct {
|
|||||||
|
|
||||||
hostClients []*fasthttp.HostClient
|
hostClients []*fasthttp.HostClient
|
||||||
responses *SarinResponseData
|
responses *SarinResponseData
|
||||||
|
fileCache *FileCache
|
||||||
|
scriptChain *script.Chain
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSarin creates a new sarin instance for load testing.
|
// NewSarin creates a new sarin instance for load testing.
|
||||||
// It can return the following errors:
|
// It can return the following errors:
|
||||||
// - types.ProxyDialError
|
// - types.ProxyDialError
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
func NewSarin(
|
func NewSarin(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
methods []string,
|
methods []string,
|
||||||
@@ -74,6 +81,8 @@ func NewSarin(
|
|||||||
values []string,
|
values []string,
|
||||||
collectStats bool,
|
collectStats bool,
|
||||||
dryRun bool,
|
dryRun bool,
|
||||||
|
luaScripts []string,
|
||||||
|
jsScripts []string,
|
||||||
) (*sarin, error) {
|
) (*sarin, error) {
|
||||||
if workers == 0 {
|
if workers == 0 {
|
||||||
workers = 1
|
workers = 1
|
||||||
@@ -84,6 +93,19 @@ func NewSarin(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load script sources
|
||||||
|
luaSources, err := script.LoadSources(ctx, luaScripts, script.EngineTypeLua)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jsSources, err := script.LoadSources(ctx, jsScripts, script.EngineTypeJavaScript)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scriptChain := script.NewChain(luaSources, jsSources)
|
||||||
|
|
||||||
srn := &sarin{
|
srn := &sarin{
|
||||||
workers: workers,
|
workers: workers,
|
||||||
requestURL: requestURL,
|
requestURL: requestURL,
|
||||||
@@ -101,6 +123,8 @@ func NewSarin(
|
|||||||
collectStats: collectStats,
|
collectStats: collectStats,
|
||||||
dryRun: dryRun,
|
dryRun: dryRun,
|
||||||
hostClients: hostClients,
|
hostClients: hostClients,
|
||||||
|
fileCache: NewFileCache(time.Second * 10),
|
||||||
|
scriptChain: scriptChain,
|
||||||
}
|
}
|
||||||
|
|
||||||
if collectStats {
|
if collectStats {
|
||||||
@@ -133,6 +157,10 @@ func (q sarin) Start(ctx context.Context) {
|
|||||||
var messageChannel chan runtimeMessage
|
var messageChannel chan runtimeMessage
|
||||||
var sendMessage messageSender
|
var sendMessage messageSender
|
||||||
|
|
||||||
|
if !q.quiet && !term.IsTerminal(os.Stdout.Fd()) {
|
||||||
|
q.quiet = true
|
||||||
|
}
|
||||||
|
|
||||||
if q.quiet {
|
if q.quiet {
|
||||||
sendMessage = func(level runtimeMessageLevel, text string) {}
|
sendMessage = func(level runtimeMessageLevel, text string) {}
|
||||||
} else {
|
} else {
|
||||||
@@ -191,7 +219,21 @@ func (q sarin) Worker(
|
|||||||
defer fasthttp.ReleaseRequest(req)
|
defer fasthttp.ReleaseRequest(req)
|
||||||
defer fasthttp.ReleaseResponse(resp)
|
defer fasthttp.ReleaseResponse(resp)
|
||||||
|
|
||||||
requestGenerator, isDynamic := NewRequestGenerator(q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values)
|
// Create script transformer for this worker (engines are not thread-safe)
|
||||||
|
// Scripts are pre-validated in NewSarin, so this should not fail
|
||||||
|
var scriptTransformer *script.Transformer
|
||||||
|
if !q.scriptChain.IsEmpty() {
|
||||||
|
var err error
|
||||||
|
scriptTransformer, err = q.scriptChain.NewTransformer()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
defer scriptTransformer.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
requestGenerator, isDynamic := NewRequestGenerator(
|
||||||
|
q.methods, q.requestURL, q.params, q.headers, q.cookies, q.bodies, q.values, q.fileCache, scriptTransformer,
|
||||||
|
)
|
||||||
|
|
||||||
if q.dryRun {
|
if q.dryRun {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package sarin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -10,9 +11,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/brianvoe/gofakeit/v7"
|
"github.com/brianvoe/gofakeit/v7"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
|
func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) template.FuncMap {
|
||||||
fakeit := gofakeit.NewFaker(randSource, false)
|
fakeit := gofakeit.NewFaker(randSource, false)
|
||||||
|
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
@@ -60,10 +62,6 @@ func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
|
|||||||
},
|
},
|
||||||
"strings_TrimPrefix": strings.TrimPrefix,
|
"strings_TrimPrefix": strings.TrimPrefix,
|
||||||
"strings_TrimSuffix": strings.TrimSuffix,
|
"strings_TrimSuffix": strings.TrimSuffix,
|
||||||
"strings_Join": func(sep string, values ...string) string {
|
|
||||||
return strings.Join(values, sep)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Dict
|
// Dict
|
||||||
"dict_Str": func(values ...string) map[string]string {
|
"dict_Str": func(values ...string) map[string]string {
|
||||||
dict := make(map[string]string)
|
dict := make(map[string]string)
|
||||||
@@ -81,6 +79,22 @@ func NewDefaultTemplateFuncMap(randSource rand.Source) template.FuncMap {
|
|||||||
"slice_Str": func(values ...string) []string { return values },
|
"slice_Str": func(values ...string) []string { return values },
|
||||||
"slice_Int": func(values ...int) []int { return values },
|
"slice_Int": func(values ...int) []int { return values },
|
||||||
"slice_Uint": func(values ...uint) []uint { return values },
|
"slice_Uint": func(values ...uint) []uint { return values },
|
||||||
|
"slice_Join": strings.Join,
|
||||||
|
|
||||||
|
// File
|
||||||
|
// file_Base64 reads a file (local or remote URL) and returns its Base64 encoded content.
|
||||||
|
// Usage: {{ file_Base64 "/path/to/file.pdf" }}
|
||||||
|
// {{ file_Base64 "https://example.com/image.png" }}
|
||||||
|
"file_Base64": func(source string) (string, error) {
|
||||||
|
if fileCache == nil {
|
||||||
|
return "", types.ErrFileCacheNotInitialized
|
||||||
|
}
|
||||||
|
cached, err := fileCache.GetOrLoad(source)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(cached.Content), nil
|
||||||
|
},
|
||||||
|
|
||||||
// Fakeit / File
|
// Fakeit / File
|
||||||
// "fakeit_CSV": fakeit.CSV(nil),
|
// "fakeit_CSV": fakeit.CSV(nil),
|
||||||
@@ -542,21 +556,75 @@ func (data *BodyTemplateFuncMapData) ClearFormDataContenType() {
|
|||||||
data.formDataContenType = ""
|
data.formDataContenType = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDefaultBodyTemplateFuncMap(randSource rand.Source, data *BodyTemplateFuncMapData) template.FuncMap {
|
func NewDefaultBodyTemplateFuncMap(
|
||||||
funcMap := NewDefaultTemplateFuncMap(randSource)
|
randSource rand.Source,
|
||||||
|
data *BodyTemplateFuncMapData,
|
||||||
|
fileCache *FileCache,
|
||||||
|
) template.FuncMap {
|
||||||
|
funcMap := NewDefaultTemplateFuncMap(randSource, fileCache)
|
||||||
|
|
||||||
if data != nil {
|
if data != nil {
|
||||||
funcMap["body_FormData"] = func(kv map[string]string) string {
|
// body_FormData creates a multipart/form-data body from key-value pairs.
|
||||||
|
// Usage: {{ body_FormData "field1" "value1" "field2" "value2" ... }}
|
||||||
|
//
|
||||||
|
// Values starting with "@" are treated as file references:
|
||||||
|
// - "@/path/to/file.txt" - local file
|
||||||
|
// - "@http://example.com/file" - remote file via HTTP
|
||||||
|
// - "@https://example.com/file" - remote file via HTTPS
|
||||||
|
//
|
||||||
|
// To send a literal string starting with "@", escape it with "@@":
|
||||||
|
// - "@@literal" sends "@literal"
|
||||||
|
//
|
||||||
|
// Example with mixed text and files:
|
||||||
|
// {{ body_FormData "name" "John" "avatar" "@/path/to/photo.jpg" "doc" "@https://example.com/file.pdf" }}
|
||||||
|
funcMap["body_FormData"] = func(pairs ...string) (string, error) {
|
||||||
|
if len(pairs)%2 != 0 {
|
||||||
|
return "", types.ErrFormDataOddArgs
|
||||||
|
}
|
||||||
|
|
||||||
var multipartData bytes.Buffer
|
var multipartData bytes.Buffer
|
||||||
writer := multipart.NewWriter(&multipartData)
|
writer := multipart.NewWriter(&multipartData)
|
||||||
data.formDataContenType = writer.FormDataContentType()
|
data.formDataContenType = writer.FormDataContentType()
|
||||||
|
|
||||||
for k, v := range kv {
|
for i := 0; i < len(pairs); i += 2 {
|
||||||
_ = writer.WriteField(k, v)
|
key := pairs[i]
|
||||||
|
val := pairs[i+1]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(val, "@@"):
|
||||||
|
// Escaped @ - send as literal string without first @
|
||||||
|
if err := writer.WriteField(key, val[1:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(val, "@"):
|
||||||
|
// File (local path or remote URL)
|
||||||
|
if fileCache == nil {
|
||||||
|
return "", types.ErrFileCacheNotInitialized
|
||||||
|
}
|
||||||
|
source := val[1:]
|
||||||
|
cached, err := fileCache.GetOrLoad(source)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
part, err := writer.CreateFormFile(key, cached.Filename)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := part.Write(cached.Content); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Regular text field
|
||||||
|
if err := writer.WriteField(key, val); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = writer.Close()
|
if err := writer.Close(); err != nil {
|
||||||
return multipartData.String()
|
return "", err
|
||||||
|
}
|
||||||
|
return multipartData.String(), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
107
internal/script/chain.go
Normal file
107
internal/script/chain.go
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chain holds the loaded script sources and can create engine instances.
|
||||||
|
// The sources are loaded once, but engines are created per-worker since they're not thread-safe.
|
||||||
|
type Chain struct {
|
||||||
|
luaSources []*Source
|
||||||
|
jsSources []*Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChain creates a new script chain from loaded sources.
|
||||||
|
// Lua scripts run first, then JavaScript scripts, in the order provided.
|
||||||
|
func NewChain(luaSources, jsSources []*Source) *Chain {
|
||||||
|
return &Chain{
|
||||||
|
luaSources: luaSources,
|
||||||
|
jsSources: jsSources,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if there are no scripts to execute.
|
||||||
|
func (c *Chain) IsEmpty() bool {
|
||||||
|
return len(c.luaSources) == 0 && len(c.jsSources) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transformer holds instantiated script engines for a single worker.
|
||||||
|
// It is NOT safe for concurrent use.
|
||||||
|
type Transformer struct {
|
||||||
|
luaEngines []*LuaEngine
|
||||||
|
jsEngines []*JsEngine
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTransformer creates engine instances from the chain's sources.
|
||||||
|
// Call this once per worker goroutine.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptChainError
|
||||||
|
func (c *Chain) NewTransformer() (*Transformer, error) {
|
||||||
|
if c.IsEmpty() {
|
||||||
|
return &Transformer{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &Transformer{
|
||||||
|
luaEngines: make([]*LuaEngine, 0, len(c.luaSources)),
|
||||||
|
jsEngines: make([]*JsEngine, 0, len(c.jsSources)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Lua engines
|
||||||
|
for i, src := range c.luaSources {
|
||||||
|
engine, err := NewLuaEngine(src.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Close() // Clean up already created engines
|
||||||
|
return nil, types.NewScriptChainError("lua", i, err)
|
||||||
|
}
|
||||||
|
t.luaEngines = append(t.luaEngines, engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JS engines
|
||||||
|
for i, src := range c.jsSources {
|
||||||
|
engine, err := NewJsEngine(src.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Close() // Clean up already created engines
|
||||||
|
return nil, types.NewScriptChainError("js", i, err)
|
||||||
|
}
|
||||||
|
t.jsEngines = append(t.jsEngines, engine)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform applies all scripts to the request data.
|
||||||
|
// Lua scripts run first, then JavaScript scripts.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptChainError
|
||||||
|
func (t *Transformer) Transform(req *RequestData) error {
|
||||||
|
// Run Lua scripts
|
||||||
|
for i, engine := range t.luaEngines {
|
||||||
|
if err := engine.Transform(req); err != nil {
|
||||||
|
return types.NewScriptChainError("lua", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run JS scripts
|
||||||
|
for i, engine := range t.jsEngines {
|
||||||
|
if err := engine.Transform(req); err != nil {
|
||||||
|
return types.NewScriptChainError("js", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases all engine resources.
|
||||||
|
func (t *Transformer) Close() {
|
||||||
|
for _, engine := range t.luaEngines {
|
||||||
|
engine.Close()
|
||||||
|
}
|
||||||
|
for _, engine := range t.jsEngines {
|
||||||
|
engine.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsEmpty returns true if there are no engines.
|
||||||
|
func (t *Transformer) IsEmpty() bool {
|
||||||
|
return len(t.luaEngines) == 0 && len(t.jsEngines) == 0
|
||||||
|
}
|
||||||
198
internal/script/js.go
Normal file
198
internal/script/js.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/dop251/goja"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JsEngine implements the Engine interface using goja (JavaScript).
|
||||||
|
type JsEngine struct {
|
||||||
|
runtime *goja.Runtime
|
||||||
|
transform goja.Callable
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJsEngine creates a new JavaScript script engine with the given script content.
|
||||||
|
// The script must define a global `transform` function that takes a request object
|
||||||
|
// and returns the modified request object.
|
||||||
|
//
|
||||||
|
// Example JavaScript script:
|
||||||
|
//
|
||||||
|
// function transform(req) {
|
||||||
|
// req.headers["X-Custom"] = ["value"];
|
||||||
|
// return req;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func NewJsEngine(scriptContent string) (*JsEngine, error) {
|
||||||
|
vm := goja.New()
|
||||||
|
|
||||||
|
// Execute the script to define the transform function
|
||||||
|
_, err := vm.RunString(scriptContent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transform function
|
||||||
|
transformVal := vm.Get("transform")
|
||||||
|
if transformVal == nil || goja.IsUndefined(transformVal) || goja.IsNull(transformVal) {
|
||||||
|
return nil, types.ErrScriptTransformMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
transform, ok := goja.AssertFunction(transformVal)
|
||||||
|
if !ok {
|
||||||
|
return nil, types.NewScriptExecutionError("JavaScript", errors.New("'transform' must be a function"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &JsEngine{
|
||||||
|
runtime: vm,
|
||||||
|
transform: transform,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform executes the JavaScript transform function with the given request data.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func (e *JsEngine) Transform(req *RequestData) error {
|
||||||
|
// Convert RequestData to JavaScript object
|
||||||
|
reqObj := e.requestDataToObject(req)
|
||||||
|
|
||||||
|
// Call transform(req)
|
||||||
|
result, err := e.transform(goja.Undefined(), reqObj)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update RequestData from the returned object
|
||||||
|
if err := e.objectToRequestData(result, req); err != nil {
|
||||||
|
return types.NewScriptExecutionError("JavaScript", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the JavaScript runtime resources.
|
||||||
|
func (e *JsEngine) Close() {
|
||||||
|
// goja doesn't have an explicit close method, but we can help GC
|
||||||
|
e.runtime = nil
|
||||||
|
e.transform = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestDataToObject converts RequestData to a goja Value (JavaScript object).
|
||||||
|
func (e *JsEngine) requestDataToObject(req *RequestData) goja.Value {
|
||||||
|
obj := e.runtime.NewObject()
|
||||||
|
|
||||||
|
_ = obj.Set("method", req.Method)
|
||||||
|
_ = obj.Set("path", req.Path)
|
||||||
|
_ = obj.Set("body", req.Body)
|
||||||
|
|
||||||
|
// Headers (map[string][]string -> object of arrays)
|
||||||
|
headers := e.runtime.NewObject()
|
||||||
|
for k, values := range req.Headers {
|
||||||
|
_ = headers.Set(k, e.stringSliceToArray(values))
|
||||||
|
}
|
||||||
|
_ = obj.Set("headers", headers)
|
||||||
|
|
||||||
|
// Params (map[string][]string -> object of arrays)
|
||||||
|
params := e.runtime.NewObject()
|
||||||
|
for k, values := range req.Params {
|
||||||
|
_ = params.Set(k, e.stringSliceToArray(values))
|
||||||
|
}
|
||||||
|
_ = obj.Set("params", params)
|
||||||
|
|
||||||
|
// Cookies (map[string][]string -> object of arrays)
|
||||||
|
cookies := e.runtime.NewObject()
|
||||||
|
for k, values := range req.Cookies {
|
||||||
|
_ = cookies.Set(k, e.stringSliceToArray(values))
|
||||||
|
}
|
||||||
|
_ = obj.Set("cookies", cookies)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectToRequestData updates RequestData from a JavaScript object.
|
||||||
|
func (e *JsEngine) objectToRequestData(val goja.Value, req *RequestData) error {
|
||||||
|
if val == nil || goja.IsUndefined(val) || goja.IsNull(val) {
|
||||||
|
return types.ErrScriptTransformReturnObject
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := val.ToObject(e.runtime)
|
||||||
|
if obj == nil {
|
||||||
|
return types.ErrScriptTransformReturnObject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method
|
||||||
|
if v := obj.Get("method"); v != nil && !goja.IsUndefined(v) {
|
||||||
|
req.Method = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path
|
||||||
|
if v := obj.Get("path"); v != nil && !goja.IsUndefined(v) {
|
||||||
|
req.Path = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if v := obj.Get("body"); v != nil && !goja.IsUndefined(v) {
|
||||||
|
req.Body = v.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if v := obj.Get("headers"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Headers = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params
|
||||||
|
if v := obj.Get("params"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Params = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
if v := obj.Get("cookies"); v != nil && !goja.IsUndefined(v) && !goja.IsNull(v) {
|
||||||
|
req.Cookies = e.objectToStringSliceMap(v.ToObject(e.runtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringSliceToArray converts a Go []string to a JavaScript array.
|
||||||
|
func (e *JsEngine) stringSliceToArray(values []string) *goja.Object {
|
||||||
|
ifaces := make([]any, len(values))
|
||||||
|
for i, v := range values {
|
||||||
|
ifaces[i] = v
|
||||||
|
}
|
||||||
|
return e.runtime.NewArray(ifaces...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectToStringSliceMap converts a JavaScript object to a Go map[string][]string.
|
||||||
|
// Supports both single string values and array values.
|
||||||
|
func (e *JsEngine) objectToStringSliceMap(obj *goja.Object) map[string][]string {
|
||||||
|
if obj == nil {
|
||||||
|
return make(map[string][]string)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string][]string)
|
||||||
|
for _, key := range obj.Keys() {
|
||||||
|
v := obj.Get(key)
|
||||||
|
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an array
|
||||||
|
if arr, ok := v.Export().([]any); ok {
|
||||||
|
var values []string
|
||||||
|
for _, item := range arr {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
values = append(values, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[key] = values
|
||||||
|
} else {
|
||||||
|
// Single value - wrap in slice
|
||||||
|
result[key] = []string{v.String()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
191
internal/script/lua.go
Normal file
191
internal/script/lua.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
lua "github.com/yuin/gopher-lua"
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LuaEngine implements the Engine interface using gopher-lua.
|
||||||
|
type LuaEngine struct {
|
||||||
|
state *lua.LState
|
||||||
|
transform *lua.LFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLuaEngine creates a new Lua script engine with the given script content.
|
||||||
|
// The script must define a global `transform` function that takes a request table
|
||||||
|
// and returns the modified request table.
|
||||||
|
//
|
||||||
|
// Example Lua script:
|
||||||
|
//
|
||||||
|
// function transform(req)
|
||||||
|
// req.headers["X-Custom"] = {"value"}
|
||||||
|
// return req
|
||||||
|
// end
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func NewLuaEngine(scriptContent string) (*LuaEngine, error) {
|
||||||
|
L := lua.NewState()
|
||||||
|
|
||||||
|
// Execute the script to define the transform function
|
||||||
|
if err := L.DoString(scriptContent); err != nil {
|
||||||
|
L.Close()
|
||||||
|
return nil, types.NewScriptExecutionError("Lua", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the transform function
|
||||||
|
transform := L.GetGlobal("transform")
|
||||||
|
if transform.Type() != lua.LTFunction {
|
||||||
|
L.Close()
|
||||||
|
return nil, types.ErrScriptTransformMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LuaEngine{
|
||||||
|
state: L,
|
||||||
|
transform: transform.(*lua.LFunction),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform executes the Lua transform function with the given request data.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
func (e *LuaEngine) Transform(req *RequestData) error {
|
||||||
|
// Convert RequestData to Lua table
|
||||||
|
reqTable := e.requestDataToTable(req)
|
||||||
|
|
||||||
|
// Call transform(req)
|
||||||
|
e.state.Push(e.transform)
|
||||||
|
e.state.Push(reqTable)
|
||||||
|
if err := e.state.PCall(1, 1, nil); err != nil {
|
||||||
|
return types.NewScriptExecutionError("Lua", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the result
|
||||||
|
result := e.state.Get(-1)
|
||||||
|
e.state.Pop(1)
|
||||||
|
|
||||||
|
if result.Type() != lua.LTTable {
|
||||||
|
return types.NewScriptExecutionError("Lua", fmt.Errorf("transform function must return a table, got %s", result.Type()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update RequestData from the returned table
|
||||||
|
e.tableToRequestData(result.(*lua.LTable), req)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the Lua state resources.
|
||||||
|
func (e *LuaEngine) Close() {
|
||||||
|
if e.state != nil {
|
||||||
|
e.state.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestDataToTable converts RequestData to a Lua table.
|
||||||
|
func (e *LuaEngine) requestDataToTable(req *RequestData) *lua.LTable {
|
||||||
|
L := e.state
|
||||||
|
t := L.NewTable()
|
||||||
|
|
||||||
|
t.RawSetString("method", lua.LString(req.Method))
|
||||||
|
t.RawSetString("path", lua.LString(req.Path))
|
||||||
|
t.RawSetString("body", lua.LString(req.Body))
|
||||||
|
|
||||||
|
// Headers (map[string][]string -> table of arrays)
|
||||||
|
headers := L.NewTable()
|
||||||
|
for k, values := range req.Headers {
|
||||||
|
arr := L.NewTable()
|
||||||
|
for _, v := range values {
|
||||||
|
arr.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
headers.RawSetString(k, arr)
|
||||||
|
}
|
||||||
|
t.RawSetString("headers", headers)
|
||||||
|
|
||||||
|
// Params (map[string][]string -> table of arrays)
|
||||||
|
params := L.NewTable()
|
||||||
|
for k, values := range req.Params {
|
||||||
|
arr := L.NewTable()
|
||||||
|
for _, v := range values {
|
||||||
|
arr.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
params.RawSetString(k, arr)
|
||||||
|
}
|
||||||
|
t.RawSetString("params", params)
|
||||||
|
|
||||||
|
// Cookies (map[string][]string -> table of arrays)
|
||||||
|
cookies := L.NewTable()
|
||||||
|
for k, values := range req.Cookies {
|
||||||
|
arr := L.NewTable()
|
||||||
|
for _, v := range values {
|
||||||
|
arr.Append(lua.LString(v))
|
||||||
|
}
|
||||||
|
cookies.RawSetString(k, arr)
|
||||||
|
}
|
||||||
|
t.RawSetString("cookies", cookies)
|
||||||
|
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableToRequestData updates RequestData from a Lua table.
|
||||||
|
func (e *LuaEngine) tableToRequestData(t *lua.LTable, req *RequestData) {
|
||||||
|
// Method
|
||||||
|
if v := t.RawGetString("method"); v.Type() == lua.LTString {
|
||||||
|
req.Method = string(v.(lua.LString))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path
|
||||||
|
if v := t.RawGetString("path"); v.Type() == lua.LTString {
|
||||||
|
req.Path = string(v.(lua.LString))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body
|
||||||
|
if v := t.RawGetString("body"); v.Type() == lua.LTString {
|
||||||
|
req.Body = string(v.(lua.LString))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headers
|
||||||
|
if v := t.RawGetString("headers"); v.Type() == lua.LTTable {
|
||||||
|
req.Headers = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params
|
||||||
|
if v := t.RawGetString("params"); v.Type() == lua.LTTable {
|
||||||
|
req.Params = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookies
|
||||||
|
if v := t.RawGetString("cookies"); v.Type() == lua.LTTable {
|
||||||
|
req.Cookies = e.tableToStringSliceMap(v.(*lua.LTable))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableToStringSliceMap converts a Lua table to a Go map[string][]string.
|
||||||
|
// Supports both single string values and array values.
|
||||||
|
func (e *LuaEngine) tableToStringSliceMap(t *lua.LTable) map[string][]string {
|
||||||
|
result := make(map[string][]string)
|
||||||
|
t.ForEach(func(k, v lua.LValue) {
|
||||||
|
if k.Type() != lua.LTString {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := string(k.(lua.LString))
|
||||||
|
|
||||||
|
switch v.Type() {
|
||||||
|
case lua.LTString:
|
||||||
|
// Single string value
|
||||||
|
result[key] = []string{string(v.(lua.LString))}
|
||||||
|
case lua.LTTable:
|
||||||
|
// Array of strings
|
||||||
|
var values []string
|
||||||
|
v.(*lua.LTable).ForEach(func(_, item lua.LValue) {
|
||||||
|
if item.Type() == lua.LTString {
|
||||||
|
values = append(values, string(item.(lua.LString)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
result[key] = values
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
197
internal/script/script.go
Normal file
197
internal/script/script.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package script
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.aykhans.me/sarin/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequestData represents the request data passed to scripts for transformation.
|
||||||
|
// Scripts can modify any field and the changes will be applied to the actual request.
|
||||||
|
// Headers, Params, and Cookies use []string values to support multiple values per key.
|
||||||
|
type RequestData struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Headers map[string][]string `json:"headers"`
|
||||||
|
Params map[string][]string `json:"params"`
|
||||||
|
Cookies map[string][]string `json:"cookies"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engine defines the interface for script engines (Lua, JavaScript).
|
||||||
|
// Each engine must be able to transform request data using a user-provided script.
|
||||||
|
type Engine interface {
|
||||||
|
// Transform executes the script's transform function with the given request data.
|
||||||
|
// The script should modify the RequestData and return it.
|
||||||
|
Transform(req *RequestData) error
|
||||||
|
|
||||||
|
// Close releases any resources held by the engine.
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineType represents the type of script engine.
|
||||||
|
type EngineType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
EngineTypeLua EngineType = "lua"
|
||||||
|
EngineTypeJavaScript EngineType = "js"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source represents a loaded script source.
|
||||||
|
type Source struct {
|
||||||
|
Content string
|
||||||
|
EngineType EngineType
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSource loads a script from the given source string.
|
||||||
|
// The source can be:
|
||||||
|
// - Inline script: any string not starting with "@"
|
||||||
|
// - Escaped "@": strings starting with "@@" (literal "@" at start, returns string without first @)
|
||||||
|
// - File reference: "@/path/to/file" or "@./relative/path"
|
||||||
|
// - URL reference: "@http://..." or "@https://..."
|
||||||
|
//
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
func LoadSource(ctx context.Context, source string, engineType EngineType) (*Source, error) {
|
||||||
|
if source == "" {
|
||||||
|
return nil, types.ErrScriptEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var content string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(source, "@@"):
|
||||||
|
// Escaped @ - it's an inline script starting with literal @
|
||||||
|
content = source[1:] // Remove first @, keep the rest
|
||||||
|
case strings.HasPrefix(source, "@"):
|
||||||
|
// File or URL reference
|
||||||
|
ref := source[1:]
|
||||||
|
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
||||||
|
content, err = fetchURL(ctx, ref)
|
||||||
|
} else {
|
||||||
|
content, err = readFile(ref)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, types.NewScriptLoadError(ref, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Inline script
|
||||||
|
content = source
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Source{
|
||||||
|
Content: content,
|
||||||
|
EngineType: engineType,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSources loads multiple script sources.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
func LoadSources(ctx context.Context, sources []string, engineType EngineType) ([]*Source, error) {
|
||||||
|
loaded := make([]*Source, 0, len(sources))
|
||||||
|
for _, src := range sources {
|
||||||
|
source, err := LoadSource(ctx, src, engineType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
loaded = append(loaded, source)
|
||||||
|
}
|
||||||
|
return loaded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateScript validates a script source by loading it and checking syntax.
|
||||||
|
// It loads the script (from file/URL/inline), parses it, and verifies
|
||||||
|
// that a 'transform' function is defined.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.ErrScriptEmpty
|
||||||
|
// - types.ErrScriptTransformMissing
|
||||||
|
// - types.ScriptLoadError
|
||||||
|
// - types.ScriptExecutionError
|
||||||
|
// - types.ScriptUnknownEngineError
|
||||||
|
func ValidateScript(ctx context.Context, source string, engineType EngineType) error {
|
||||||
|
// Load the script source
|
||||||
|
src, err := LoadSource(ctx, source, engineType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to create an engine - this validates syntax and transform function
|
||||||
|
var engine Engine
|
||||||
|
switch engineType {
|
||||||
|
case EngineTypeLua:
|
||||||
|
engine, err = NewLuaEngine(src.Content)
|
||||||
|
case EngineTypeJavaScript:
|
||||||
|
engine, err = NewJsEngine(src.Content)
|
||||||
|
default:
|
||||||
|
return types.NewScriptUnknownEngineError(string(engineType))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up the engine - we only needed it for validation
|
||||||
|
engine.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchURL downloads content from an HTTP/HTTPS URL.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.HTTPFetchError
|
||||||
|
// - types.HTTPStatusError
|
||||||
|
func fetchURL(ctx context.Context, url string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close() //nolint:errcheck
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewHTTPFetchError(url, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFile reads content from a local file.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - types.FileReadError
|
||||||
|
func readFile(path string) (string, error) {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
pwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewFileReadError(path, err)
|
||||||
|
}
|
||||||
|
path = filepath.Join(pwd, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path) //nolint:gosec
|
||||||
|
if err != nil {
|
||||||
|
return "", types.NewFileReadError(path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ func (cookies Cookies) GetValue(key string) *[]string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cookies *Cookies) Append(cookie ...Cookie) {
|
func (cookies *Cookies) Merge(cookie ...Cookie) {
|
||||||
for _, c := range cookie {
|
for _, c := range cookie {
|
||||||
if item := cookies.GetValue(c.Key); item != nil {
|
if item := cookies.GetValue(c.Key); item != nil {
|
||||||
*item = append(*item, c.Value...)
|
*item = append(*item, c.Value...)
|
||||||
@@ -27,7 +27,7 @@ func (cookies *Cookies) Append(cookie ...Cookie) {
|
|||||||
|
|
||||||
func (cookies *Cookies) Parse(rawValues ...string) {
|
func (cookies *Cookies) Parse(rawValues ...string) {
|
||||||
for _, rawValue := range rawValues {
|
for _, rawValue := range rawValues {
|
||||||
cookies.Append(*ParseCookie(rawValue))
|
*cookies = append(*cookies, *ParseCookie(rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
// General
|
|
||||||
ErrNoError = errors.New("no error (internal)")
|
|
||||||
|
|
||||||
// CLI
|
|
||||||
ErrCLINoArgs = errors.New("CLI expects arguments but received none")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ======================================== General ========================================
|
// ======================================== General ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
errNoError = errors.New("no error (internal)")
|
||||||
|
)
|
||||||
|
|
||||||
type FieldParseError struct {
|
type FieldParseError struct {
|
||||||
Field string
|
Field string
|
||||||
Value string
|
Value string
|
||||||
@@ -24,7 +20,7 @@ type FieldParseError struct {
|
|||||||
|
|
||||||
func NewFieldParseError(field string, value string, err error) FieldParseError {
|
func NewFieldParseError(field string, value string, err error) FieldParseError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return FieldParseError{field, value, err}
|
return FieldParseError{field, value, err}
|
||||||
}
|
}
|
||||||
@@ -72,7 +68,7 @@ type FieldValidationError struct {
|
|||||||
|
|
||||||
func NewFieldValidationError(field string, value string, err error) FieldValidationError {
|
func NewFieldValidationError(field string, value string, err error) FieldValidationError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return FieldValidationError{field, value, err}
|
return FieldValidationError{field, value, err}
|
||||||
}
|
}
|
||||||
@@ -118,7 +114,7 @@ type UnmarshalError struct {
|
|||||||
|
|
||||||
func NewUnmarshalError(err error) UnmarshalError {
|
func NewUnmarshalError(err error) UnmarshalError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return UnmarshalError{err}
|
return UnmarshalError{err}
|
||||||
}
|
}
|
||||||
@@ -131,6 +127,127 @@ func (e UnmarshalError) Unwrap() error {
|
|||||||
return e.error
|
return e.error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================================== General I/O ========================================
|
||||||
|
|
||||||
|
type FileReadError struct {
|
||||||
|
Path string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileReadError(path string, err error) FileReadError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return FileReadError{path, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FileReadError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to read file %s: %v", e.Path, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e FileReadError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPFetchError struct {
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPFetchError(url string, err error) HTTPFetchError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return HTTPFetchError{url, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPFetchError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to fetch %s: %v", e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPFetchError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPStatusError struct {
|
||||||
|
URL string
|
||||||
|
StatusCode int
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPStatusError(url string, statusCode int, status string) HTTPStatusError {
|
||||||
|
return HTTPStatusError{url, statusCode, status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e HTTPStatusError) Error() string {
|
||||||
|
return fmt.Sprintf("HTTP %d %s (url: %s)", e.StatusCode, e.Status, e.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
type URLParseError struct {
|
||||||
|
URL string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewURLParseError(url string, err error) URLParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return URLParseError{url, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e URLParseError) Error() string {
|
||||||
|
return fmt.Sprintf("invalid URL %q: %v", e.URL, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e URLParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================================== Template ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrFileCacheNotInitialized = errors.New("file cache is not initialized")
|
||||||
|
ErrFormDataOddArgs = errors.New("body_FormData requires an even number of arguments (key-value pairs)")
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateParseError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateParseError(err error) TemplateParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return TemplateParseError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateParseError) Error() string {
|
||||||
|
return "template parse error: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateRenderError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateRenderError(err error) TemplateRenderError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return TemplateRenderError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateRenderError) Error() string {
|
||||||
|
return "template rendering: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e TemplateRenderError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
// ======================================== CLI ========================================
|
// ======================================== CLI ========================================
|
||||||
|
|
||||||
type CLIUnexpectedArgsError struct {
|
type CLIUnexpectedArgsError struct {
|
||||||
@@ -153,7 +270,7 @@ type ConfigFileReadError struct {
|
|||||||
|
|
||||||
func NewConfigFileReadError(err error) ConfigFileReadError {
|
func NewConfigFileReadError(err error) ConfigFileReadError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return ConfigFileReadError{err}
|
return ConfigFileReadError{err}
|
||||||
}
|
}
|
||||||
@@ -168,6 +285,61 @@ func (e ConfigFileReadError) Unwrap() error {
|
|||||||
|
|
||||||
// ======================================== Proxy ========================================
|
// ======================================== Proxy ========================================
|
||||||
|
|
||||||
|
type ProxyUnsupportedSchemeError struct {
|
||||||
|
Scheme string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyUnsupportedSchemeError(scheme string) ProxyUnsupportedSchemeError {
|
||||||
|
return ProxyUnsupportedSchemeError{scheme}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyUnsupportedSchemeError) Error() string {
|
||||||
|
return "unsupported proxy scheme: " + e.Scheme
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyParseError struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyParseError(err error) ProxyParseError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ProxyParseError{err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyParseError) Error() string {
|
||||||
|
return "failed to parse proxy URL: " + e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyParseError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyConnectError struct {
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyConnectError(status string) ProxyConnectError {
|
||||||
|
return ProxyConnectError{status}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyConnectError) Error() string {
|
||||||
|
return "proxy CONNECT failed: " + e.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyResolveError struct {
|
||||||
|
Host string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProxyResolveError(host string) ProxyResolveError {
|
||||||
|
return ProxyResolveError{host}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ProxyResolveError) Error() string {
|
||||||
|
return "no IP addresses found for host: " + e.Host
|
||||||
|
}
|
||||||
|
|
||||||
type ProxyDialError struct {
|
type ProxyDialError struct {
|
||||||
Proxy string
|
Proxy string
|
||||||
Err error
|
Err error
|
||||||
@@ -175,7 +347,7 @@ type ProxyDialError struct {
|
|||||||
|
|
||||||
func NewProxyDialError(proxy string, err error) ProxyDialError {
|
func NewProxyDialError(proxy string, err error) ProxyDialError {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = ErrNoError
|
err = errNoError
|
||||||
}
|
}
|
||||||
return ProxyDialError{proxy, err}
|
return ProxyDialError{proxy, err}
|
||||||
}
|
}
|
||||||
@@ -187,3 +359,86 @@ func (e ProxyDialError) Error() string {
|
|||||||
func (e ProxyDialError) Unwrap() error {
|
func (e ProxyDialError) Unwrap() error {
|
||||||
return e.Err
|
return e.Err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================================== Script ========================================
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrScriptEmpty = errors.New("script cannot be empty")
|
||||||
|
ErrScriptSourceEmpty = errors.New("script source cannot be empty after @")
|
||||||
|
ErrScriptTransformMissing = errors.New("script must define a global 'transform' function")
|
||||||
|
ErrScriptTransformReturnObject = errors.New("transform function must return an object")
|
||||||
|
ErrScriptURLNoHost = errors.New("script URL must have a host")
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptLoadError struct {
|
||||||
|
Source string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptLoadError(source string, err error) ScriptLoadError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptLoadError{source, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptLoadError) Error() string {
|
||||||
|
return fmt.Sprintf("failed to load script from %q: %v", e.Source, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptLoadError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptExecutionError struct {
|
||||||
|
EngineType string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptExecutionError(engineType string, err error) ScriptExecutionError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptExecutionError{engineType, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptExecutionError) Error() string {
|
||||||
|
return fmt.Sprintf("%s script error: %v", e.EngineType, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptExecutionError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptChainError struct {
|
||||||
|
EngineType string
|
||||||
|
Index int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptChainError(engineType string, index int, err error) ScriptChainError {
|
||||||
|
if err == nil {
|
||||||
|
err = errNoError
|
||||||
|
}
|
||||||
|
return ScriptChainError{engineType, index, err}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptChainError) Error() string {
|
||||||
|
return fmt.Sprintf("%s script[%d]: %v", e.EngineType, e.Index, e.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptChainError) Unwrap() error {
|
||||||
|
return e.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptUnknownEngineError struct {
|
||||||
|
EngineType string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError {
|
||||||
|
return ScriptUnknownEngineError{engineType}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e ScriptUnknownEngineError) Error() string {
|
||||||
|
return "unknown engine type: " + e.EngineType
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (headers Headers) GetValue(key string) *[]string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (headers *Headers) Append(header ...Header) {
|
func (headers *Headers) Merge(header ...Header) {
|
||||||
for _, h := range header {
|
for _, h := range header {
|
||||||
if item := headers.GetValue(h.Key); item != nil {
|
if item := headers.GetValue(h.Key); item != nil {
|
||||||
*item = append(*item, h.Value...)
|
*item = append(*item, h.Value...)
|
||||||
@@ -36,7 +36,7 @@ func (headers *Headers) Append(header ...Header) {
|
|||||||
|
|
||||||
func (headers *Headers) Parse(rawValues ...string) {
|
func (headers *Headers) Parse(rawValues ...string) {
|
||||||
for _, rawValue := range rawValues {
|
for _, rawValue := range rawValues {
|
||||||
headers.Append(*ParseHeader(rawValue))
|
*headers = append(*headers, *ParseHeader(rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func (params Params) GetValue(key string) *[]string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (params *Params) Append(param ...Param) {
|
func (params *Params) Merge(param ...Param) {
|
||||||
for _, p := range param {
|
for _, p := range param {
|
||||||
if item := params.GetValue(p.Key); item != nil {
|
if item := params.GetValue(p.Key); item != nil {
|
||||||
*item = append(*item, p.Value...)
|
*item = append(*item, p.Value...)
|
||||||
@@ -27,7 +27,7 @@ func (params *Params) Append(param ...Param) {
|
|||||||
|
|
||||||
func (params *Params) Parse(rawValues ...string) {
|
func (params *Params) Parse(rawValues ...string) {
|
||||||
for _, rawValue := range rawValues {
|
for _, rawValue := range rawValues {
|
||||||
params.Append(*ParseParam(rawValue))
|
*params = append(*params, *ParseParam(rawValue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +16,9 @@ func (proxies *Proxies) Append(proxy ...Proxy) {
|
|||||||
*proxies = append(*proxies, proxy...)
|
*proxies = append(*proxies, proxy...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse parses a raw proxy string and appends it to the list.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ProxyParseError
|
||||||
func (proxies *Proxies) Parse(rawValue string) error {
|
func (proxies *Proxies) Parse(rawValue string) error {
|
||||||
parsedProxy, err := ParseProxy(rawValue)
|
parsedProxy, err := ParseProxy(rawValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,10 +29,13 @@ func (proxies *Proxies) Parse(rawValue string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseProxy parses a raw proxy URL string into a Proxy.
|
||||||
|
// It can return the following errors:
|
||||||
|
// - ProxyParseError
|
||||||
func ParseProxy(rawValue string) (*Proxy, error) {
|
func ParseProxy(rawValue string) (*Proxy, error) {
|
||||||
urlParsed, err := url.Parse(rawValue)
|
urlParsed, err := url.Parse(rawValue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse proxy URL: %w", err)
|
return nil, NewProxyParseError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyParsed := Proxy(*urlParsed)
|
proxyParsed := Proxy(*urlParsed)
|
||||||
|
|||||||
Reference in New Issue
Block a user