From a2a11ab50299ac02051a55e7184c3cb544f2d057 Mon Sep 17 00:00:00 2001 From: aperezg <4472006+aperezg@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:35:41 +0100 Subject: [PATCH 1/7] start tune-up of the project --- .github/workflows/{main.yaml => ci-test.yaml} | 13 ++++---- AGENTS.md | 32 +++++++++++++++++++ acceptance/acceptance_test.go | 15 ++++----- 3 files changed, 46 insertions(+), 14 deletions(-) rename .github/workflows/{main.yaml => ci-test.yaml} (71%) create mode 100644 AGENTS.md diff --git a/.github/workflows/main.yaml b/.github/workflows/ci-test.yaml similarity index 71% rename from .github/workflows/main.yaml rename to .github/workflows/ci-test.yaml index 2a66f8e..d7efc81 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/ci-test.yaml @@ -3,10 +3,9 @@ name: Test & Build on: pull_request: push: - paths: - - 'cmd/**' - - 'internal/**' - - 'acceptance/**' + +permissions: + contents: read jobs: build: @@ -22,6 +21,8 @@ jobs: with: go-version: ${{ matrix.go }} - name: Run unit tests - run: go test -v -vet=off -race ./... + run: go test -race ./... + - name: Run static checks + run: go vet ./... - name: Run acceptance tests - run: make acceptance \ No newline at end of file + run: make acceptance diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e23c0ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +Killgrave is a Go HTTP mock server. `cmd/killgrave/main.go` is the executable entry point; `internal/app/` wires the application together, and `internal/app/cmd/` defines the Cobra CLI. Configuration and file watching live in `internal/`; HTTP routing, imposters, proxying, and response handling live in `internal/server/http/`. + +Unit tests sit beside source files as `*_test.go`. Fixtures live in `internal/test/testdata/` and `internal/server/http/test/testdata/`. Process-level acceptance tests and `.txtar` fixtures live in `acceptance/`. Consult `README.md` for configuration and imposter examples. + +## Build, Test, and Development Commands + +Use Go 1.21, matching `go.mod` and CI. + +- `make build`: produce `bin/killgrave` with branch and commit version metadata. +- `./bin/killgrave --imposters ./internal/server/http/test/testdata/imposters`: run locally with existing fixtures on localhost:3000. +- `go test ./...`: run the default unit test suite. +- `go test -v -vet=off -race ./...`: reproduce the CI unit test command with race detection. +- `make acceptance`: build the executable, then run acceptance tests with the `acceptance` build tag. +- `gofmt -w path/to/changed.go`: format changed Go files; use `go vet ./...` for static checks. + +## Coding Style & Naming Conventions + +Follow existing Go conventions: use `gofmt` formatting and tab indentation, short lowercase package names, exported `CamelCase` identifiers, and unexported `camelCase` identifiers. Keep related behavior in the existing packages. Group standard-library, external, and repository imports consistently with nearby code. Name imposter fixtures with `.imp.json`, `.imp.yml`, or `.imp.yaml` extensions. + +## Testing Guidelines + +Use Go's `testing` package with Testify assertions. Name tests `Test` and use descriptive table-driven subtests for input variations. Add regression coverage for behavior changes, including error cases. Acceptance scenarios belong under `acceptance/tests//`, with `config.txtar` and HTTP request/response archives in `http/`. Run unit and acceptance suites before submitting. No numeric coverage threshold is configured. + +## Commit & Pull Request Guidelines + +Recent commits use concise action-oriented subjects, such as `Fix imposters search` and `Replace spf13/afero with io/fs`; merged subjects often include a PR number. Follow that style. + +Follow `CONTRIBUTING.md`: ask to take an issue, confirm its milestone, and base work on the milestone branch. PR descriptions should explain the change, link the issue, and list validation performed. For bugs, include reproduction steps, expected versus actual behavior, and relevant configuration or imposters. diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index d2a1b6b..27522a6 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -97,6 +97,7 @@ func Test(t *testing.T) { // Send the request res, err := http.DefaultClient.Do(rr.req) require.NoError(t, err) + t.Cleanup(func() { res.Body.Close() }) // Assert the res rr.assertResponse(t, res) @@ -250,7 +251,7 @@ func readRequest(t *testing.T, raw []byte) *http.Request { // calling app.Run(). func createTmpCfgDir(t *testing.T, tc testCase) string { // First, we read the `config.txtar` file and initialize a txtar.Archive with its contents. - tmpCfgDir := filepath.Join(os.TempDir(), tc.name) + tmpCfgDir := t.TempDir() cfgFilePath := filepath.Join(tc.path, "config.txtar") contents, err := os.ReadFile(cfgFilePath) require.NoError(t, err) @@ -268,12 +269,6 @@ func createTmpCfgDir(t *testing.T, tc testCase) string { require.NoError(t, err) } - // Tell the testing framework to clean up the temporary directory after the test is done. - t.Cleanup(func() { - err := os.RemoveAll(tmpCfgDir) - require.NoError(t, err) - }) - return tmpCfgDir } @@ -314,7 +309,11 @@ func runApplication(t *testing.T, from string) string { ) require.Eventually(t, func() bool { res, err := http.Get("http://" + address + "/nonExistingEndpoint") - return err == nil && res != nil && res.StatusCode == http.StatusNotFound + if err != nil { + return false + } + defer res.Body.Close() + return res.StatusCode == http.StatusNotFound }, maxWaitTime, checkEvery) return address From 6e11f75fae19674a304d21de024500fd59fba59c Mon Sep 17 00:00:00 2001 From: aperezg <4472006+aperezg@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:50:10 +0100 Subject: [PATCH 2/7] upgrade library versions --- .github/workflows/ci-test.yaml | 11 +++++++---- .github/workflows/release.yml | 10 +++++----- AGENTS.md | 2 +- Dockerfile | 4 ++-- go.mod | 16 +++++++--------- go.sum | 31 +++++++++++++++---------------- 6 files changed, 37 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index d7efc81..f68ea2a 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -1,7 +1,6 @@ name: Test & Build on: - pull_request: push: permissions: @@ -12,12 +11,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: [ '1.21' ] + go: [ '1.27' ] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: ${{ matrix.go }} - name: Run unit tests @@ -26,3 +25,7 @@ jobs: run: go vet ./... - name: Run acceptance tests run: make acceptance + - name: Check for vulnerabilities + run: | + go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... + go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -tags=acceptance ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f018cc5..408e435 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,13 +10,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.21' + go-version: '1.27' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 if: startsWith(github.ref, 'refs/tags/') @@ -32,7 +32,7 @@ jobs: name: Deploy to DockerHub runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -61,4 +61,4 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: true - tags: ${{ steps.meta.outputs.tags }} \ No newline at end of file + tags: ${{ steps.meta.outputs.tags }} diff --git a/AGENTS.md b/AGENTS.md index e23c0ec..da5ff87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Unit tests sit beside source files as `*_test.go`. Fixtures live in `internal/te ## Build, Test, and Development Commands -Use Go 1.21, matching `go.mod` and CI. +Use Go 1.27, matching `go.mod` and CI. - `make build`: produce `bin/killgrave` with branch and commit version metadata. - `./bin/killgrave --imposters ./internal/server/http/test/testdata/imposters`: run locally with existing fixtures on localhost:3000. diff --git a/Dockerfile b/Dockerfile index 85e5ad7..0f3595b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.21-alpine AS build +FROM golang:1.27-alpine AS build LABEL MAINTAINER = 'Friends of Go (it@friendsofgo.tech)' @@ -17,4 +17,4 @@ RUN go mod tidy && TAG=$(git describe --tags --abbrev=0) \ FROM scratch COPY --from=build /go/bin/killgrave /go/bin/killgrave COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ -ENTRYPOINT ["/go/bin/killgrave"] \ No newline at end of file +ENTRYPOINT ["/go/bin/killgrave"] diff --git a/go.mod b/go.mod index 4288f68..8d9a898 100644 --- a/go.mod +++ b/go.mod @@ -1,27 +1,25 @@ module github.com/friendsofgo/killgrave -go 1.21 +go 1.27.0 require ( github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/radovskyb/watcher v1.0.7 - github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.10.0 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.12.1 github.com/xeipuuv/gojsonschema v1.2.0 - golang.org/x/tools v0.24.0 + golang.org/x/tools v0.52.0 gopkg.in/yaml.v2 v2.4.0 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9843ed8..1eed5d6 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,8 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -19,21 +17,21 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -41,12 +39,13 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs= +golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= From 6dc42829a152df8f23eea3b180808a31827bf01c Mon Sep 17 00:00:00 2001 From: aperezg <4472006+aperezg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:05:43 +0100 Subject: [PATCH 3/7] Expand acceptance coverage for configuration, matching, proxying, and startup errors --- acceptance/README.md | 56 +++++ acceptance/acceptance_test.go | 229 +++++++++++++----- acceptance/tests/config_file/config.txtar | 8 + .../tests/config_file/http/configured.txtar | 11 + acceptance/tests/config_file/scenario.json | 6 + .../tests/config_precedence/config.txtar | 6 + .../config_precedence/http/configured.txtar | 11 + .../tests/config_precedence/scenario.json | 12 + acceptance/tests/matching/config.txtar | 80 ++++++ .../http/matching_missing_header.txtar | 11 + .../http/matching_missing_query.txtar | 12 + .../tests/matching/http/matching_valid.txtar | 12 + .../matching/http/matching_wrong_header.txtar | 12 + .../matching/http/matching_wrong_path.txtar | 12 + .../matching/http/matching_wrong_query.txtar | 12 + .../tests/matching/http/schema_empty.txtar | 13 + .../tests/matching/http/schema_invalid.txtar | 14 ++ .../matching/http/schema_malformed.txtar | 14 ++ .../matching/http/schema_second_schema.txtar | 14 ++ .../tests/matching/http/schema_valid.txtar | 14 ++ .../tests/matching/http/unknown_route.txtar | 12 + .../tests/matching/http/wrong_method.txtar | 9 + acceptance/tests/proxy_all/config.txtar | 4 + acceptance/tests/proxy_all/http/missing.txtar | 18 ++ acceptance/tests/proxy_all/http/mock.txtar | 18 ++ .../proxy_all/http/schema_rejected.txtar | 18 ++ acceptance/tests/proxy_all/scenario.json | 13 + acceptance/tests/proxy_missing/config.txtar | 11 + .../tests/proxy_missing/http/missing.txtar | 18 ++ .../tests/proxy_missing/http/mock.txtar | 14 ++ .../proxy_missing/http/schema_rejected.txtar | 18 ++ acceptance/tests/proxy_missing/scenario.json | 7 + acceptance/tests/proxy_none/config.txtar | 4 + .../tests/proxy_none/http/missing.txtar | 14 ++ acceptance/tests/proxy_none/http/mock.txtar | 14 ++ .../proxy_none/http/schema_rejected.txtar | 14 ++ acceptance/tests/proxy_none/scenario.json | 13 + .../tests/proxy_unavailable/config.txtar | 2 + .../proxy_unavailable/http/unavailable.txtar | 9 + .../tests/proxy_unavailable/scenario.json | 14 ++ .../startup_invalid_proxy_mode/config.txtar | 1 + .../startup_invalid_proxy_mode/scenario.json | 8 + .../startup_malformed_config/config.txtar | 2 + .../startup_malformed_config/scenario.json | 8 + .../tests/startup_missing_config/config.txtar | 1 + .../startup_missing_config/scenario.json | 8 + .../startup_missing_imposters/config.txtar | 1 + .../startup_missing_imposters/scenario.json | 8 + .../startup_missing_proxy_url/config.txtar | 1 + .../startup_missing_proxy_url/scenario.json | 8 + .../tests/startup_occupied_port/config.txtar | 4 + .../tests/startup_occupied_port/scenario.json | 12 + 52 files changed, 803 insertions(+), 62 deletions(-) create mode 100644 acceptance/README.md create mode 100644 acceptance/tests/config_file/config.txtar create mode 100644 acceptance/tests/config_file/http/configured.txtar create mode 100644 acceptance/tests/config_file/scenario.json create mode 100644 acceptance/tests/config_precedence/config.txtar create mode 100644 acceptance/tests/config_precedence/http/configured.txtar create mode 100644 acceptance/tests/config_precedence/scenario.json create mode 100644 acceptance/tests/matching/config.txtar create mode 100644 acceptance/tests/matching/http/matching_missing_header.txtar create mode 100644 acceptance/tests/matching/http/matching_missing_query.txtar create mode 100644 acceptance/tests/matching/http/matching_valid.txtar create mode 100644 acceptance/tests/matching/http/matching_wrong_header.txtar create mode 100644 acceptance/tests/matching/http/matching_wrong_path.txtar create mode 100644 acceptance/tests/matching/http/matching_wrong_query.txtar create mode 100644 acceptance/tests/matching/http/schema_empty.txtar create mode 100644 acceptance/tests/matching/http/schema_invalid.txtar create mode 100644 acceptance/tests/matching/http/schema_malformed.txtar create mode 100644 acceptance/tests/matching/http/schema_second_schema.txtar create mode 100644 acceptance/tests/matching/http/schema_valid.txtar create mode 100644 acceptance/tests/matching/http/unknown_route.txtar create mode 100644 acceptance/tests/matching/http/wrong_method.txtar create mode 100644 acceptance/tests/proxy_all/config.txtar create mode 100644 acceptance/tests/proxy_all/http/missing.txtar create mode 100644 acceptance/tests/proxy_all/http/mock.txtar create mode 100644 acceptance/tests/proxy_all/http/schema_rejected.txtar create mode 100644 acceptance/tests/proxy_all/scenario.json create mode 100644 acceptance/tests/proxy_missing/config.txtar create mode 100644 acceptance/tests/proxy_missing/http/missing.txtar create mode 100644 acceptance/tests/proxy_missing/http/mock.txtar create mode 100644 acceptance/tests/proxy_missing/http/schema_rejected.txtar create mode 100644 acceptance/tests/proxy_missing/scenario.json create mode 100644 acceptance/tests/proxy_none/config.txtar create mode 100644 acceptance/tests/proxy_none/http/missing.txtar create mode 100644 acceptance/tests/proxy_none/http/mock.txtar create mode 100644 acceptance/tests/proxy_none/http/schema_rejected.txtar create mode 100644 acceptance/tests/proxy_none/scenario.json create mode 100644 acceptance/tests/proxy_unavailable/config.txtar create mode 100644 acceptance/tests/proxy_unavailable/http/unavailable.txtar create mode 100644 acceptance/tests/proxy_unavailable/scenario.json create mode 100644 acceptance/tests/startup_invalid_proxy_mode/config.txtar create mode 100644 acceptance/tests/startup_invalid_proxy_mode/scenario.json create mode 100644 acceptance/tests/startup_malformed_config/config.txtar create mode 100644 acceptance/tests/startup_malformed_config/scenario.json create mode 100644 acceptance/tests/startup_missing_config/config.txtar create mode 100644 acceptance/tests/startup_missing_config/scenario.json create mode 100644 acceptance/tests/startup_missing_imposters/config.txtar create mode 100644 acceptance/tests/startup_missing_imposters/scenario.json create mode 100644 acceptance/tests/startup_missing_proxy_url/config.txtar create mode 100644 acceptance/tests/startup_missing_proxy_url/scenario.json create mode 100644 acceptance/tests/startup_occupied_port/config.txtar create mode 100644 acceptance/tests/startup_occupied_port/scenario.json diff --git a/acceptance/README.md b/acceptance/README.md new file mode 100644 index 0000000..4f9e869 --- /dev/null +++ b/acceptance/README.md @@ -0,0 +1,56 @@ +# Acceptance tests + +Run `make acceptance` from the repository root. This builds the binary before +testing it as a separate process. Scenarios run concurrently without external +network services. + +Each directory in `tests/` contains: + +- `config.txtar`: files to extract into an isolated temporary working directory. + Paths are relative to that directory. The archive can contain only a comment + when a startup error needs no files. +- `http/*.txtar`: a `req.http` and `res.http` pair per exchange. These run in + filename order within a scenario. Expected responses include all headers + except `Date`, which the harness removes. Redirects are asserted directly. +- Optional `scenario.json`: startup arguments and additional expectations. + +Without `scenario.json`, Killgrave starts with `--imposters /imposters` +and an available port. If `args` is present, it replaces those default arguments. +Arguments and extracted file contents support `${PORT}` and `${UPSTREAM}` +substitution. Arguments run directly without a shell. + +For example, to exercise configuration from a different directory: + +```json +{ + "args": ["--config", "settings/config.yml"] +} +``` + +The extracted config must include `port: ${PORT}`. Its imposter path should be +relative to the config's directory, rather than the process working directory. +The `config_precedence` scenario records the current behavior: config-file host, +port, and imposter settings take precedence over the corresponding flags. + +Other settings: + +| Setting | Meaning | +| --- | --- | +| `error` | Expected diagnostic substring; require an unsuccessful exit within five seconds. No HTTP fixtures are used. | +| `occupyPort` | Hold the allocated port open to exercise bind failure. | +| `upstreamCalls` | Start a local upstream and assert this exact request count. Zero also verifies that traffic stays local. | +| `upstreamDown` | Close that upstream before launching Killgrave to exercise a connection failure. | + +The upstream returns status 202, `X-Upstream: yes`, and a text body echoing the +method, request URI, `X-Test` header, and request body. Proxy fixtures use these +to check forwarding as well as upstream response propagation. Readiness uses a +TCP connection, so it neither calls the upstream nor advances a response sequence. + +HTTP calls time out after three seconds; readiness and startup-error commands +have five-second limits. Cleanup allows three seconds for an interrupt before +killing and reaping the process. Server logs are attached to failed scenarios. + +Current groups cover config-file paths and precedence, matching/schema fallback, +proxy modes (including schema-rejected bodies and unavailable upstreams), startup +errors, and the original simple resource example. Malformed imposter handling is +not yet specified by this suite; malformed startup configuration is covered. diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 27522a6..e21081a 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -5,12 +5,14 @@ package acceptance import ( "bufio" "bytes" + "context" + "encoding/json" "errors" "fmt" "io" - "io/fs" - "log" + "net" "net/http" + "net/http/httptest" "net/url" "os" "os/exec" @@ -18,6 +20,7 @@ import ( "regexp" "strconv" "strings" + "sync/atomic" "testing" "time" @@ -47,25 +50,19 @@ func Test(t *testing.T) { // This is useful, not only to write a log that can serve as metadata for the test results, // but also to ensure that the Killgrave binary is available. version, err := extractKillgraveVersion(t) - if err != nil { - var pathErr *fs.PathError - if errors.As(err, &pathErr) || strings.Contains(err.Error(), "executable file not found in $PATH") { - log.Fatalf("Attention! It looks like you haven't compiled Killgrave, the execution of the Killgrave "+ - "binary has failed with: %v", err) - } - log.Fatalf("The execution of `killgrave version` has failed with: %v", err) - } + require.NoError(t, err, "build Killgrave with make build before running acceptance tests") t.Logf("Running acceptance tests with Killgrave version: %s", version) - // Once we now that the Killgrave binary is available, we can proceed with the acceptance tests. + // Once we know the Killgrave binary is available, proceed with the acceptance tests. // The first step is to collect all test cases from the `tests` directory. For each test: // // 0. The test case self-contain on each directory, which name is used as the test name. // 1. Requires a `config.txtar` file at the root level of the test case directory, // it is used to initialize a file system with all the configuration-related files, // which not only includes the Killgrave configuration file but also the imposters. - // 2. Runs Killgrave in any available port (so we can run multiple test cases at the same - // time), using the configuration files from the previous step. + // 2. Uses optional scenario.json startup settings, or the default imposter flags. + // Startup-error scenarios assert an unsuccessful exit instead of sending HTTP. + // Each server runs on an available port so scenarios can run concurrently. // 3. Requires an `http` directory which contains a set of request and response pairs. // Each pair is defined by two files: `req.http` and `res.http`, where the request // is the HTTP request that will be performed as part of the test, and the response @@ -80,10 +77,55 @@ func Test(t *testing.T) { t.Parallel() // 1. Create a temporary directory with the configuration files. - path := createTmpCfgDir(t, tc) + opts := readScenario(t, tc.path) + port, err := network.AnyAvailablePort() + require.NoError(t, err) + var calls atomic.Int32 + upstreamURL := "" + if opts.UpstreamCalls != nil { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("X-Upstream", "yes") + w.WriteHeader(http.StatusAccepted) + fmt.Fprintf(w, "%s %s\nX-Test: %s\n%s", r.Method, r.URL.RequestURI(), r.Header.Get("X-Test"), body) + })) + t.Cleanup(upstream.Close) + upstreamURL = upstream.URL + if opts.UpstreamDown { + upstream.Close() + } + } + replace := strings.NewReplacer("${PORT}", strconv.Itoa(port), "${UPSTREAM}", upstreamURL) + path := createTmpCfgDir(t, tc, replace) + if opts.OccupyPort { + listener, err := net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(port))) + require.NoError(t, err) + t.Cleanup(func() { listener.Close() }) + } // 2. Start the Killgrave process. - address := runApplication(t, path) + args := []string{"-P", strconv.Itoa(port), "--imposters", filepath.Join(path, "imposters")} + if opts.Args != nil { + args = make([]string, len(opts.Args)) + for i, arg := range opts.Args { + args[i] = replace.Replace(arg) + } + } + if opts.Error != "" { + assertStartupError(t, path, args, opts.Error) + return + } + address := net.JoinHostPort(addr, strconv.Itoa(port)) + runApplication(t, path, address, args) + client := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} // 3. Collect the request and response pairs // and iterate over them to perform the tests. @@ -95,7 +137,7 @@ func Test(t *testing.T) { rr.overrideAddress(address) // Send the request - res, err := http.DefaultClient.Do(rr.req) + res, err := client.Do(rr.req) require.NoError(t, err) t.Cleanup(func() { res.Body.Close() }) @@ -103,6 +145,9 @@ func Test(t *testing.T) { rr.assertResponse(t, res) }) } + if opts.UpstreamCalls != nil { + assert.Equal(t, *opts.UpstreamCalls, int(calls.Load()), "upstream request count") + } }) } } @@ -249,7 +294,7 @@ func readRequest(t *testing.T, raw []byte) *http.Request { // Additionally, in the future we might explore ways to reuse this setup to run these tests // as "integration tests", so faking using a fake, likely in-memory, file system but directly // calling app.Run(). -func createTmpCfgDir(t *testing.T, tc testCase) string { +func createTmpCfgDir(t *testing.T, tc testCase, replace *strings.Replacer) string { // First, we read the `config.txtar` file and initialize a txtar.Archive with its contents. tmpCfgDir := t.TempDir() cfgFilePath := filepath.Join(tc.path, "config.txtar") @@ -265,68 +310,128 @@ func createTmpCfgDir(t *testing.T, tc testCase) string { err := os.MkdirAll(fileDir, os.ModePerm) require.NoError(t, err) - err = os.WriteFile(filePath, f.Data, os.ModePerm) + err = os.WriteFile(filePath, []byte(replace.Replace(string(f.Data))), 0o600) require.NoError(t, err) } return tmpCfgDir } -// runApplication runs Killgrave assuming the binary already exists. -// It uses the imposters located at `from`, which path must be absolute. -// It runs the application on any available port, so we can run multiple -// tests concurrently. It returns the address as the first return value. -// -// For now, it redirects the application's output (stdout and stderr) -// to the test's output, but in the future, we might want to capture -// the output to assert the logs, and or use it in a smarter way. -func runApplication(t *testing.T, from string) string { - // Look for any available port. - port, err := network.AnyAvailablePort() - address := addr + ":" + strconv.Itoa(port) - require.NoError(t, err, "failed to find an available port") - - // Prepare the `killgrave` command, and start it. - cmd := exec.Command(bin, "-P", strconv.Itoa(port), "--imposters", filepath.Join(from, "imposters")) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - err = cmd.Start() +// scenario contains optional startup settings. Args are passed literally, with only +// ${PORT} and ${UPSTREAM} substituted; no shell is involved. +type scenario struct { + Args []string `json:"args"` + Error string `json:"error"` + OccupyPort bool `json:"occupyPort"` + UpstreamCalls *int `json:"upstreamCalls"` + UpstreamDown bool `json:"upstreamDown"` +} + +func readScenario(t *testing.T, path string) scenario { + t.Helper() + data, err := os.ReadFile(filepath.Join(path, "scenario.json")) + if errors.Is(err, os.ErrNotExist) { + return scenario{} + } require.NoError(t, err) + var opts scenario + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + require.NoError(t, decoder.Decode(&opts)) + return opts +} - // Tell the testing framework to stop the process after the test is done. - t.Cleanup(func() { - err := cmd.Process.Signal(os.Interrupt) - require.NoError(t, err) +func applicationCommand(t *testing.T, dir string, args []string) *exec.Cmd { + t.Helper() + binary, err := filepath.Abs(bin) + require.NoError(t, err) + cmd := exec.Command(binary, args...) + cmd.Dir = dir + return cmd +} - err = cmd.Wait() - require.NoError(t, err) - }) +func assertStartupError(t *testing.T, dir string, args []string, message string) { + t.Helper() + cmd := applicationCommand(t, dir, args) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd = exec.CommandContext(ctx, cmd.Path, args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + require.NoError(t, ctx.Err(), "startup timed out: %s", output) + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr, "expected unsuccessful exit: %s", output) + assert.Contains(t, string(output), message) +} - // Wait for the application to be ready. - const ( - maxWaitTime = 2 * time.Second - checkEvery = 100 * time.Millisecond - ) - require.Eventually(t, func() bool { - res, err := http.Get("http://" + address + "/nonExistingEndpoint") - if err != nil { - return false +// runApplication captures output in a file so concurrent writes and diagnostics +// cannot race. TCP readiness does not consume responses or hit a proxy upstream. +func runApplication(t *testing.T, dir, address string, args []string) { + t.Helper() + cmd := applicationCommand(t, dir, args) + outputPath := filepath.Join(dir, "process.log") + output, err := os.Create(outputPath) + require.NoError(t, err) + t.Cleanup(func() { + output.Close() + if t.Failed() { + data, _ := os.ReadFile(outputPath) + t.Logf("Killgrave output:\n%s", data) } - defer res.Body.Close() - return res.StatusCode == http.StatusNotFound - }, maxWaitTime, checkEvery) - - return address + }) + cmd.Stdout, cmd.Stderr = output, output + require.NoError(t, cmd.Start()) + done := make(chan struct{}) + var waitErr error + go func() { + waitErr = cmd.Wait() + close(done) + }() + t.Cleanup(func() { + select { + case <-done: + default: + _ = cmd.Process.Signal(os.Interrupt) + select { + case <-done: + case <-time.After(3 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Error("Killgrave did not stop within 3 seconds") + } + } + assert.NoError(t, waitErr, "Killgrave process exit") + }) + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + tick := time.NewTicker(20 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-done: + t.Fatalf("Killgrave exited before becoming ready: %v", waitErr) + case <-deadline.C: + t.Fatal("Killgrave did not become ready within 5 seconds") + case <-tick.C: + conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond) + if err == nil { + conn.Close() + return + } + } + } } -// extractKillgraveVersion runs the `killgrave version` command and uses a regular expression +// extractKillgraveVersion runs `killgrave --version` and uses a regular expression // to extract the version from the output. In case there's any error (e.g. the binary is not // available), it returns the error. func extractKillgraveVersion(t *testing.T) (string, error) { t.Helper() - // Prepare the `killgrave version` command. - cmd := exec.Command(bin, "version", "-v") + // Bound even the initial binary check so a broken command cannot hang the suite. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "--version") // Capture the command's output. out := new(bytes.Buffer) @@ -339,7 +444,7 @@ func extractKillgraveVersion(t *testing.T) (string, error) { } // Extract the Killgrave version from the output. - re := regexp.MustCompile(`Killgrave version:\s*([a-zA-Z0-9\-]+)`) + re := regexp.MustCompile(`Killgrave version:\s*(\S+)`) match := re.FindStringSubmatch(out.String()) if len(match) == 2 { return match[1], nil diff --git a/acceptance/tests/config_file/config.txtar b/acceptance/tests/config_file/config.txtar new file mode 100644 index 0000000..0af5ea1 --- /dev/null +++ b/acceptance/tests/config_file/config.txtar @@ -0,0 +1,8 @@ +-- settings/config.yml -- +host: 127.0.0.1 +port: ${PORT} +imposters_path: imposters +-- settings/imposters/nested/configured.imp.json -- +[{"request": {"method": "GET", "endpoint": "/configured"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "bodyFile": "responses/body.txt"}}] +-- settings/imposters/nested/responses/body.txt -- +configured diff --git a/acceptance/tests/config_file/http/configured.txtar b/acceptance/tests/config_file/http/configured.txtar new file mode 100644 index 0000000..114978e --- /dev/null +++ b/acceptance/tests/config_file/http/configured.txtar @@ -0,0 +1,11 @@ +-- req.http -- +GET /configured HTTP/1.1 + + +-- res.http -- +HTTP/1.1 200 OK +Content-Length: 11 +Content-Type: text/plain + + +configured diff --git a/acceptance/tests/config_file/scenario.json b/acceptance/tests/config_file/scenario.json new file mode 100644 index 0000000..f6b3426 --- /dev/null +++ b/acceptance/tests/config_file/scenario.json @@ -0,0 +1,6 @@ +{ + "args": [ + "--config", + "settings/config.yml" + ] +} diff --git a/acceptance/tests/config_precedence/config.txtar b/acceptance/tests/config_precedence/config.txtar new file mode 100644 index 0000000..dffa748 --- /dev/null +++ b/acceptance/tests/config_precedence/config.txtar @@ -0,0 +1,6 @@ +-- settings/config.yml -- +host: 127.0.0.1 +port: ${PORT} +imposters_path: imposters +-- settings/imposters/configured.imp.json -- +[{"request": {"method": "GET", "endpoint": "/configured"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "config wins\n"}}] diff --git a/acceptance/tests/config_precedence/http/configured.txtar b/acceptance/tests/config_precedence/http/configured.txtar new file mode 100644 index 0000000..b056e3e --- /dev/null +++ b/acceptance/tests/config_precedence/http/configured.txtar @@ -0,0 +1,11 @@ +-- req.http -- +GET /configured HTTP/1.1 + + +-- res.http -- +HTTP/1.1 200 OK +Content-Length: 12 +Content-Type: text/plain + + +config wins diff --git a/acceptance/tests/config_precedence/scenario.json b/acceptance/tests/config_precedence/scenario.json new file mode 100644 index 0000000..f7ea922 --- /dev/null +++ b/acceptance/tests/config_precedence/scenario.json @@ -0,0 +1,12 @@ +{ + "args": [ + "--config", + "settings/config.yml", + "--port", + "-1", + "--host", + "invalid.invalid", + "--imposters", + "missing" + ] +} diff --git a/acceptance/tests/matching/config.txtar b/acceptance/tests/matching/config.txtar new file mode 100644 index 0000000..7941cad --- /dev/null +++ b/acceptance/tests/matching/config.txtar @@ -0,0 +1,80 @@ +-- imposters/routes.imp.json -- +[ + { + "request": { + "method": "POST", + "endpoint": "/animals", + "schemaFile": "schemas/gopher.json" + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "text/plain" + }, + "body": "gopher\n" + } + }, + { + "request": { + "method": "POST", + "endpoint": "/animals", + "schemaFile": "schemas/cat.json" + }, + "response": { + "status": 202, + "headers": { + "Content-Type": "text/plain" + }, + "body": "cat\n" + } + }, + { + "request": { + "method": "POST", + "endpoint": "/animals" + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "text/plain" + }, + "body": "fallback\n" + } + }, + { + "request": { + "method": "GET", + "endpoint": "/restricted/{id:[0-9]+}", + "headers": { + "X-Key": "^token[a-z]+$" + }, + "params": { + "color": "{color:red|blue}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "matched\n" + } + }, + { + "request": { + "method": "GET", + "endpoint": "/restricted/{id}" + }, + "response": { + "status": 418, + "headers": { + "Content-Type": "text/plain" + }, + "body": "fallback\n" + } + } +] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} +-- imposters/schemas/cat.json -- +{"type": "object", "properties": {"type": {"enum": ["cat"]}}, "required": ["type"]} diff --git a/acceptance/tests/matching/http/matching_missing_header.txtar b/acceptance/tests/matching/http/matching_missing_header.txtar new file mode 100644 index 0000000..effffb0 --- /dev/null +++ b/acceptance/tests/matching/http/matching_missing_header.txtar @@ -0,0 +1,11 @@ +-- req.http -- +GET /restricted/42?color=red HTTP/1.1 + + +-- res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/matching_missing_query.txtar b/acceptance/tests/matching/http/matching_missing_query.txtar new file mode 100644 index 0000000..390e1e9 --- /dev/null +++ b/acceptance/tests/matching/http/matching_missing_query.txtar @@ -0,0 +1,12 @@ +-- req.http -- +GET /restricted/42 HTTP/1.1 +X-Key: tokenabc + + +-- res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/matching_valid.txtar b/acceptance/tests/matching/http/matching_valid.txtar new file mode 100644 index 0000000..a7f9422 --- /dev/null +++ b/acceptance/tests/matching/http/matching_valid.txtar @@ -0,0 +1,12 @@ +-- req.http -- +GET /restricted/42?color=red HTTP/1.1 +X-Key: tokenabc + + +-- res.http -- +HTTP/1.1 200 OK +Content-Length: 8 +Content-Type: text/plain + + +matched diff --git a/acceptance/tests/matching/http/matching_wrong_header.txtar b/acceptance/tests/matching/http/matching_wrong_header.txtar new file mode 100644 index 0000000..0e1dc24 --- /dev/null +++ b/acceptance/tests/matching/http/matching_wrong_header.txtar @@ -0,0 +1,12 @@ +-- req.http -- +GET /restricted/42?color=red HTTP/1.1 +X-Key: invalid + + +-- res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/matching_wrong_path.txtar b/acceptance/tests/matching/http/matching_wrong_path.txtar new file mode 100644 index 0000000..1809eb3 --- /dev/null +++ b/acceptance/tests/matching/http/matching_wrong_path.txtar @@ -0,0 +1,12 @@ +-- req.http -- +GET /restricted/abc?color=red HTTP/1.1 +X-Key: tokenabc + + +-- res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/matching_wrong_query.txtar b/acceptance/tests/matching/http/matching_wrong_query.txtar new file mode 100644 index 0000000..bdf32d7 --- /dev/null +++ b/acceptance/tests/matching/http/matching_wrong_query.txtar @@ -0,0 +1,12 @@ +-- req.http -- +GET /restricted/42?color=green HTTP/1.1 +X-Key: tokenabc + + +-- res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/schema_empty.txtar b/acceptance/tests/matching/http/schema_empty.txtar new file mode 100644 index 0000000..cfcaa36 --- /dev/null +++ b/acceptance/tests/matching/http/schema_empty.txtar @@ -0,0 +1,13 @@ +-- req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 0 + + +-- res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/schema_invalid.txtar b/acceptance/tests/matching/http/schema_invalid.txtar new file mode 100644 index 0000000..8b70a4c --- /dev/null +++ b/acceptance/tests/matching/http/schema_invalid.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 15 + +{"type":"dog"} + +-- res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/schema_malformed.txtar b/acceptance/tests/matching/http/schema_malformed.txtar new file mode 100644 index 0000000..1a2b315 --- /dev/null +++ b/acceptance/tests/matching/http/schema_malformed.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 8 + +{broken + +-- res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/tests/matching/http/schema_second_schema.txtar b/acceptance/tests/matching/http/schema_second_schema.txtar new file mode 100644 index 0000000..6b0af36 --- /dev/null +++ b/acceptance/tests/matching/http/schema_second_schema.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 15 + +{"type":"cat"} + +-- res.http -- +HTTP/1.1 202 Accepted +Content-Length: 4 +Content-Type: text/plain + + +cat diff --git a/acceptance/tests/matching/http/schema_valid.txtar b/acceptance/tests/matching/http/schema_valid.txtar new file mode 100644 index 0000000..279e4a2 --- /dev/null +++ b/acceptance/tests/matching/http/schema_valid.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 18 + +{"type":"gopher"} + +-- res.http -- +HTTP/1.1 201 Created +Content-Length: 7 +Content-Type: text/plain + + +gopher diff --git a/acceptance/tests/matching/http/unknown_route.txtar b/acceptance/tests/matching/http/unknown_route.txtar new file mode 100644 index 0000000..1cb0cbc --- /dev/null +++ b/acceptance/tests/matching/http/unknown_route.txtar @@ -0,0 +1,12 @@ +-- req.http -- +GET /unknown HTTP/1.1 + + +-- res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found diff --git a/acceptance/tests/matching/http/wrong_method.txtar b/acceptance/tests/matching/http/wrong_method.txtar new file mode 100644 index 0000000..fcc21f5 --- /dev/null +++ b/acceptance/tests/matching/http/wrong_method.txtar @@ -0,0 +1,9 @@ +-- req.http -- +DELETE /animals HTTP/1.1 + + +-- res.http -- +HTTP/1.1 405 Method Not Allowed +Content-Length: 0 + + diff --git a/acceptance/tests/proxy_all/config.txtar b/acceptance/tests/proxy_all/config.txtar new file mode 100644 index 0000000..8986c17 --- /dev/null +++ b/acceptance/tests/proxy_all/config.txtar @@ -0,0 +1,4 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} diff --git a/acceptance/tests/proxy_all/http/missing.txtar b/acceptance/tests/proxy_all/http/missing.txtar new file mode 100644 index 0000000..9223272 --- /dev/null +++ b/acceptance/tests/proxy_all/http/missing.txtar @@ -0,0 +1,18 @@ +-- req.http -- +POST /upstream/items?color=red&n=2 HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 8 + +payload + +-- res.http -- +HTTP/1.1 202 Accepted +Content-Length: 61 +Content-Type: text/plain +X-Upstream: yes + + +POST /upstream/items?color=red&n=2 +X-Test: preserved +payload diff --git a/acceptance/tests/proxy_all/http/mock.txtar b/acceptance/tests/proxy_all/http/mock.txtar new file mode 100644 index 0000000..c86ba74 --- /dev/null +++ b/acceptance/tests/proxy_all/http/mock.txtar @@ -0,0 +1,18 @@ +-- req.http -- +POST /mock HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 8 + +payload + +-- res.http -- +HTTP/1.1 202 Accepted +Content-Length: 37 +Content-Type: text/plain +X-Upstream: yes + + +POST /mock +X-Test: preserved +payload diff --git a/acceptance/tests/proxy_all/http/schema_rejected.txtar b/acceptance/tests/proxy_all/http/schema_rejected.txtar new file mode 100644 index 0000000..b864b3c --- /dev/null +++ b/acceptance/tests/proxy_all/http/schema_rejected.txtar @@ -0,0 +1,18 @@ +-- req.http -- +POST /submit HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 15 + +{"type":"cat"} + +-- res.http -- +HTTP/1.1 202 Accepted +Content-Length: 46 +Content-Type: text/plain +X-Upstream: yes + + +POST /submit +X-Test: preserved +{"type":"cat"} diff --git a/acceptance/tests/proxy_all/scenario.json b/acceptance/tests/proxy_all/scenario.json new file mode 100644 index 0000000..d74c08c --- /dev/null +++ b/acceptance/tests/proxy_all/scenario.json @@ -0,0 +1,13 @@ +{ + "upstreamCalls": 3, + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--proxy-mode", + "all", + "--proxy-url", + "${UPSTREAM}" + ] +} diff --git a/acceptance/tests/proxy_missing/config.txtar b/acceptance/tests/proxy_missing/config.txtar new file mode 100644 index 0000000..3784ddb --- /dev/null +++ b/acceptance/tests/proxy_missing/config.txtar @@ -0,0 +1,11 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} +-- config.yml -- +host: localhost +port: ${PORT} +imposters_path: imposters +proxy: + mode: missing + url: ${UPSTREAM} diff --git a/acceptance/tests/proxy_missing/http/missing.txtar b/acceptance/tests/proxy_missing/http/missing.txtar new file mode 100644 index 0000000..9223272 --- /dev/null +++ b/acceptance/tests/proxy_missing/http/missing.txtar @@ -0,0 +1,18 @@ +-- req.http -- +POST /upstream/items?color=red&n=2 HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 8 + +payload + +-- res.http -- +HTTP/1.1 202 Accepted +Content-Length: 61 +Content-Type: text/plain +X-Upstream: yes + + +POST /upstream/items?color=red&n=2 +X-Test: preserved +payload diff --git a/acceptance/tests/proxy_missing/http/mock.txtar b/acceptance/tests/proxy_missing/http/mock.txtar new file mode 100644 index 0000000..e652d9f --- /dev/null +++ b/acceptance/tests/proxy_missing/http/mock.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /mock HTTP/1.1 +X-Test: preserved +Content-Length: 8 + +payload + +-- res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +mocked diff --git a/acceptance/tests/proxy_missing/http/schema_rejected.txtar b/acceptance/tests/proxy_missing/http/schema_rejected.txtar new file mode 100644 index 0000000..b864b3c --- /dev/null +++ b/acceptance/tests/proxy_missing/http/schema_rejected.txtar @@ -0,0 +1,18 @@ +-- req.http -- +POST /submit HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 15 + +{"type":"cat"} + +-- res.http -- +HTTP/1.1 202 Accepted +Content-Length: 46 +Content-Type: text/plain +X-Upstream: yes + + +POST /submit +X-Test: preserved +{"type":"cat"} diff --git a/acceptance/tests/proxy_missing/scenario.json b/acceptance/tests/proxy_missing/scenario.json new file mode 100644 index 0000000..203a4ed --- /dev/null +++ b/acceptance/tests/proxy_missing/scenario.json @@ -0,0 +1,7 @@ +{ + "upstreamCalls": 2, + "args": [ + "--config", + "config.yml" + ] +} diff --git a/acceptance/tests/proxy_none/config.txtar b/acceptance/tests/proxy_none/config.txtar new file mode 100644 index 0000000..8986c17 --- /dev/null +++ b/acceptance/tests/proxy_none/config.txtar @@ -0,0 +1,4 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} diff --git a/acceptance/tests/proxy_none/http/missing.txtar b/acceptance/tests/proxy_none/http/missing.txtar new file mode 100644 index 0000000..43865f2 --- /dev/null +++ b/acceptance/tests/proxy_none/http/missing.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /upstream/items?color=red&n=2 HTTP/1.1 +Content-Length: 8 + +payload + +-- res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found diff --git a/acceptance/tests/proxy_none/http/mock.txtar b/acceptance/tests/proxy_none/http/mock.txtar new file mode 100644 index 0000000..e652d9f --- /dev/null +++ b/acceptance/tests/proxy_none/http/mock.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /mock HTTP/1.1 +X-Test: preserved +Content-Length: 8 + +payload + +-- res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +mocked diff --git a/acceptance/tests/proxy_none/http/schema_rejected.txtar b/acceptance/tests/proxy_none/http/schema_rejected.txtar new file mode 100644 index 0000000..61a0d46 --- /dev/null +++ b/acceptance/tests/proxy_none/http/schema_rejected.txtar @@ -0,0 +1,14 @@ +-- req.http -- +POST /submit HTTP/1.1 +Content-Length: 15 + +{"type":"cat"} + +-- res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found diff --git a/acceptance/tests/proxy_none/scenario.json b/acceptance/tests/proxy_none/scenario.json new file mode 100644 index 0000000..9f77b90 --- /dev/null +++ b/acceptance/tests/proxy_none/scenario.json @@ -0,0 +1,13 @@ +{ + "upstreamCalls": 0, + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--proxy-mode", + "none", + "--proxy-url", + "${UPSTREAM}" + ] +} diff --git a/acceptance/tests/proxy_unavailable/config.txtar b/acceptance/tests/proxy_unavailable/config.txtar new file mode 100644 index 0000000..8094baf --- /dev/null +++ b/acceptance/tests/proxy_unavailable/config.txtar @@ -0,0 +1,2 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "GET", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mock\n"}}] diff --git a/acceptance/tests/proxy_unavailable/http/unavailable.txtar b/acceptance/tests/proxy_unavailable/http/unavailable.txtar new file mode 100644 index 0000000..ba90bdd --- /dev/null +++ b/acceptance/tests/proxy_unavailable/http/unavailable.txtar @@ -0,0 +1,9 @@ +-- req.http -- +GET /missing HTTP/1.1 + + +-- res.http -- +HTTP/1.1 502 Bad Gateway +Content-Length: 0 + + diff --git a/acceptance/tests/proxy_unavailable/scenario.json b/acceptance/tests/proxy_unavailable/scenario.json new file mode 100644 index 0000000..6fab2a5 --- /dev/null +++ b/acceptance/tests/proxy_unavailable/scenario.json @@ -0,0 +1,14 @@ +{ + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--proxy-mode", + "all", + "--proxy-url", + "${UPSTREAM}" + ], + "upstreamCalls": 0, + "upstreamDown": true +} diff --git a/acceptance/tests/startup_invalid_proxy_mode/config.txtar b/acceptance/tests/startup_invalid_proxy_mode/config.txtar new file mode 100644 index 0000000..b3cf461 --- /dev/null +++ b/acceptance/tests/startup_invalid_proxy_mode/config.txtar @@ -0,0 +1 @@ +No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_invalid_proxy_mode/scenario.json b/acceptance/tests/startup_invalid_proxy_mode/scenario.json new file mode 100644 index 0000000..b9cffcb --- /dev/null +++ b/acceptance/tests/startup_invalid_proxy_mode/scenario.json @@ -0,0 +1,8 @@ +{ + "args": [ + "--proxy-mode", + "invalid" + ], + "error": "unknown proxy mode: invalid", + "occupyPort": false +} diff --git a/acceptance/tests/startup_malformed_config/config.txtar b/acceptance/tests/startup_malformed_config/config.txtar new file mode 100644 index 0000000..0f2c034 --- /dev/null +++ b/acceptance/tests/startup_malformed_config/config.txtar @@ -0,0 +1,2 @@ +-- bad.yml -- +host: [ diff --git a/acceptance/tests/startup_malformed_config/scenario.json b/acceptance/tests/startup_malformed_config/scenario.json new file mode 100644 index 0000000..06bbb90 --- /dev/null +++ b/acceptance/tests/startup_malformed_config/scenario.json @@ -0,0 +1,8 @@ +{ + "args": [ + "--config", + "bad.yml" + ], + "error": "error while unmarshalling configFile", + "occupyPort": false +} diff --git a/acceptance/tests/startup_missing_config/config.txtar b/acceptance/tests/startup_missing_config/config.txtar new file mode 100644 index 0000000..b3cf461 --- /dev/null +++ b/acceptance/tests/startup_missing_config/config.txtar @@ -0,0 +1 @@ +No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_missing_config/scenario.json b/acceptance/tests/startup_missing_config/scenario.json new file mode 100644 index 0000000..3ce2f6a --- /dev/null +++ b/acceptance/tests/startup_missing_config/scenario.json @@ -0,0 +1,8 @@ +{ + "args": [ + "--config", + "missing.yml" + ], + "error": "error trying to read config file", + "occupyPort": false +} diff --git a/acceptance/tests/startup_missing_imposters/config.txtar b/acceptance/tests/startup_missing_imposters/config.txtar new file mode 100644 index 0000000..b3cf461 --- /dev/null +++ b/acceptance/tests/startup_missing_imposters/config.txtar @@ -0,0 +1 @@ +No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_missing_imposters/scenario.json b/acceptance/tests/startup_missing_imposters/scenario.json new file mode 100644 index 0000000..fd8b550 --- /dev/null +++ b/acceptance/tests/startup_missing_imposters/scenario.json @@ -0,0 +1,8 @@ +{ + "args": [ + "--imposters", + "missing" + ], + "error": "does not exist", + "occupyPort": false +} diff --git a/acceptance/tests/startup_missing_proxy_url/config.txtar b/acceptance/tests/startup_missing_proxy_url/config.txtar new file mode 100644 index 0000000..b3cf461 --- /dev/null +++ b/acceptance/tests/startup_missing_proxy_url/config.txtar @@ -0,0 +1 @@ +No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_missing_proxy_url/scenario.json b/acceptance/tests/startup_missing_proxy_url/scenario.json new file mode 100644 index 0000000..01aa8c1 --- /dev/null +++ b/acceptance/tests/startup_missing_proxy_url/scenario.json @@ -0,0 +1,8 @@ +{ + "args": [ + "--proxy-mode", + "missing" + ], + "error": "proxy-url is mandatory", + "occupyPort": false +} diff --git a/acceptance/tests/startup_occupied_port/config.txtar b/acceptance/tests/startup_occupied_port/config.txtar new file mode 100644 index 0000000..4572da9 --- /dev/null +++ b/acceptance/tests/startup_occupied_port/config.txtar @@ -0,0 +1,4 @@ +-- bad.yml -- +host: [ +-- imposters/routes.imp.json -- +[{"request": {"method": "GET", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mock\n"}}] diff --git a/acceptance/tests/startup_occupied_port/scenario.json b/acceptance/tests/startup_occupied_port/scenario.json new file mode 100644 index 0000000..f8ec8a3 --- /dev/null +++ b/acceptance/tests/startup_occupied_port/scenario.json @@ -0,0 +1,12 @@ +{ + "args": [ + "--host", + "localhost", + "--port", + "${PORT}", + "--imposters", + "imposters" + ], + "error": "address already in use", + "occupyPort": true +} From 3964a752dfdc0be373f0957e50632155263985d4 Mon Sep 17 00:00:00 2001 From: aperezg <4472006+aperezg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:25:23 +0100 Subject: [PATCH 4/7] Simplify acceptance tests --- AGENTS.md | 2 +- acceptance/README.md | 101 ++++---- acceptance/acceptance_test.go | 234 +++++++++-------- .../config.txtar => config_file.txtar} | 18 ++ .../tests/config_file/http/configured.txtar | 11 - acceptance/tests/config_file/scenario.json | 6 - acceptance/tests/config_precedence.txtar | 30 +++ .../tests/config_precedence/config.txtar | 6 - .../config_precedence/http/configured.txtar | 11 - .../tests/config_precedence/scenario.json | 12 - acceptance/tests/cors_configured.txtar | 119 +++++++++ acceptance/tests/cors_defaults.txtar | 42 +++ acceptance/tests/matching.txtar | 241 ++++++++++++++++++ acceptance/tests/matching/config.txtar | 80 ------ .../http/matching_missing_header.txtar | 11 - .../http/matching_missing_query.txtar | 12 - .../tests/matching/http/matching_valid.txtar | 12 - .../matching/http/matching_wrong_header.txtar | 12 - .../matching/http/matching_wrong_path.txtar | 12 - .../matching/http/matching_wrong_query.txtar | 12 - .../tests/matching/http/schema_empty.txtar | 13 - .../tests/matching/http/schema_invalid.txtar | 14 - .../matching/http/schema_malformed.txtar | 14 - .../matching/http/schema_second_schema.txtar | 14 - .../tests/matching/http/schema_valid.txtar | 14 - .../tests/matching/http/unknown_route.txtar | 12 - .../tests/matching/http/wrong_method.txtar | 9 - acceptance/tests/proxy_all.txtar | 72 ++++++ acceptance/tests/proxy_all/config.txtar | 4 - acceptance/tests/proxy_all/http/missing.txtar | 18 -- acceptance/tests/proxy_all/http/mock.txtar | 18 -- .../proxy_all/http/schema_rejected.txtar | 18 -- acceptance/tests/proxy_all/scenario.json | 13 - acceptance/tests/proxy_missing.txtar | 69 +++++ acceptance/tests/proxy_missing/config.txtar | 11 - .../tests/proxy_missing/http/missing.txtar | 18 -- .../tests/proxy_missing/http/mock.txtar | 14 - .../proxy_missing/http/schema_rejected.txtar | 18 -- acceptance/tests/proxy_missing/scenario.json | 7 - acceptance/tests/proxy_none.txtar | 60 +++++ acceptance/tests/proxy_none/config.txtar | 4 - .../tests/proxy_none/http/missing.txtar | 14 - acceptance/tests/proxy_none/http/mock.txtar | 14 - .../proxy_none/http/schema_rejected.txtar | 14 - acceptance/tests/proxy_none/scenario.json | 13 - acceptance/tests/proxy_unavailable.txtar | 26 ++ .../tests/proxy_unavailable/config.txtar | 2 - .../proxy_unavailable/http/unavailable.txtar | 9 - .../tests/proxy_unavailable/scenario.json | 14 - acceptance/tests/response_sequences.txtar | 239 +++++++++++++++++ .../{simple/config.txtar => simple.txtar} | 56 +++- acceptance/tests/simple/http/create.txtar | 24 -- .../tests/simple/http/fetchExisting.txtar | 21 -- .../tests/simple/http/fetchNotFound.txtar | 9 - ....json => startup_invalid_proxy_mode.txtar} | 2 + .../startup_invalid_proxy_mode/config.txtar | 1 - ...io.json => startup_malformed_config.txtar} | 3 + .../startup_malformed_config/config.txtar | 2 - ...ario.json => startup_missing_config.txtar} | 2 + .../tests/startup_missing_config/config.txtar | 1 - ...o.json => startup_missing_imposters.txtar} | 2 + .../startup_missing_imposters/config.txtar | 1 - ...o.json => startup_missing_proxy_url.txtar} | 2 + .../startup_missing_proxy_url/config.txtar | 1 - ...nfig.txtar => startup_occupied_port.txtar} | 13 + .../tests/startup_occupied_port/scenario.json | 12 - acceptance/tests/watcher_config.txtar | 111 ++++++++ acceptance/tests/watcher_flag.txtar | 108 ++++++++ acceptance/tests/yaml_nested.txtar | 166 ++++++++++++ acceptance/utils/network/network.go | 19 -- 70 files changed, 1568 insertions(+), 751 deletions(-) rename acceptance/tests/{config_file/config.txtar => config_file.txtar} (59%) delete mode 100644 acceptance/tests/config_file/http/configured.txtar delete mode 100644 acceptance/tests/config_file/scenario.json create mode 100644 acceptance/tests/config_precedence.txtar delete mode 100644 acceptance/tests/config_precedence/config.txtar delete mode 100644 acceptance/tests/config_precedence/http/configured.txtar delete mode 100644 acceptance/tests/config_precedence/scenario.json create mode 100644 acceptance/tests/cors_configured.txtar create mode 100644 acceptance/tests/cors_defaults.txtar create mode 100644 acceptance/tests/matching.txtar delete mode 100644 acceptance/tests/matching/config.txtar delete mode 100644 acceptance/tests/matching/http/matching_missing_header.txtar delete mode 100644 acceptance/tests/matching/http/matching_missing_query.txtar delete mode 100644 acceptance/tests/matching/http/matching_valid.txtar delete mode 100644 acceptance/tests/matching/http/matching_wrong_header.txtar delete mode 100644 acceptance/tests/matching/http/matching_wrong_path.txtar delete mode 100644 acceptance/tests/matching/http/matching_wrong_query.txtar delete mode 100644 acceptance/tests/matching/http/schema_empty.txtar delete mode 100644 acceptance/tests/matching/http/schema_invalid.txtar delete mode 100644 acceptance/tests/matching/http/schema_malformed.txtar delete mode 100644 acceptance/tests/matching/http/schema_second_schema.txtar delete mode 100644 acceptance/tests/matching/http/schema_valid.txtar delete mode 100644 acceptance/tests/matching/http/unknown_route.txtar delete mode 100644 acceptance/tests/matching/http/wrong_method.txtar create mode 100644 acceptance/tests/proxy_all.txtar delete mode 100644 acceptance/tests/proxy_all/config.txtar delete mode 100644 acceptance/tests/proxy_all/http/missing.txtar delete mode 100644 acceptance/tests/proxy_all/http/mock.txtar delete mode 100644 acceptance/tests/proxy_all/http/schema_rejected.txtar delete mode 100644 acceptance/tests/proxy_all/scenario.json create mode 100644 acceptance/tests/proxy_missing.txtar delete mode 100644 acceptance/tests/proxy_missing/config.txtar delete mode 100644 acceptance/tests/proxy_missing/http/missing.txtar delete mode 100644 acceptance/tests/proxy_missing/http/mock.txtar delete mode 100644 acceptance/tests/proxy_missing/http/schema_rejected.txtar delete mode 100644 acceptance/tests/proxy_missing/scenario.json create mode 100644 acceptance/tests/proxy_none.txtar delete mode 100644 acceptance/tests/proxy_none/config.txtar delete mode 100644 acceptance/tests/proxy_none/http/missing.txtar delete mode 100644 acceptance/tests/proxy_none/http/mock.txtar delete mode 100644 acceptance/tests/proxy_none/http/schema_rejected.txtar delete mode 100644 acceptance/tests/proxy_none/scenario.json create mode 100644 acceptance/tests/proxy_unavailable.txtar delete mode 100644 acceptance/tests/proxy_unavailable/config.txtar delete mode 100644 acceptance/tests/proxy_unavailable/http/unavailable.txtar delete mode 100644 acceptance/tests/proxy_unavailable/scenario.json create mode 100644 acceptance/tests/response_sequences.txtar rename acceptance/tests/{simple/config.txtar => simple.txtar} (57%) delete mode 100644 acceptance/tests/simple/http/create.txtar delete mode 100644 acceptance/tests/simple/http/fetchExisting.txtar delete mode 100644 acceptance/tests/simple/http/fetchNotFound.txtar rename acceptance/tests/{startup_invalid_proxy_mode/scenario.json => startup_invalid_proxy_mode.txtar} (55%) delete mode 100644 acceptance/tests/startup_invalid_proxy_mode/config.txtar rename acceptance/tests/{startup_malformed_config/scenario.json => startup_malformed_config.txtar} (74%) delete mode 100644 acceptance/tests/startup_malformed_config/config.txtar rename acceptance/tests/{startup_missing_config/scenario.json => startup_missing_config.txtar} (56%) delete mode 100644 acceptance/tests/startup_missing_config/config.txtar rename acceptance/tests/{startup_missing_imposters/scenario.json => startup_missing_imposters.txtar} (52%) delete mode 100644 acceptance/tests/startup_missing_imposters/config.txtar rename acceptance/tests/{startup_missing_proxy_url/scenario.json => startup_missing_proxy_url.txtar} (54%) delete mode 100644 acceptance/tests/startup_missing_proxy_url/config.txtar rename acceptance/tests/{startup_occupied_port/config.txtar => startup_occupied_port.txtar} (50%) delete mode 100644 acceptance/tests/startup_occupied_port/scenario.json create mode 100644 acceptance/tests/watcher_config.txtar create mode 100644 acceptance/tests/watcher_flag.txtar create mode 100644 acceptance/tests/yaml_nested.txtar delete mode 100644 acceptance/utils/network/network.go diff --git a/AGENTS.md b/AGENTS.md index da5ff87..76360e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ Follow existing Go conventions: use `gofmt` formatting and tab indentation, shor ## Testing Guidelines -Use Go's `testing` package with Testify assertions. Name tests `Test` and use descriptive table-driven subtests for input variations. Add regression coverage for behavior changes, including error cases. Acceptance scenarios belong under `acceptance/tests//`, with `config.txtar` and HTTP request/response archives in `http/`. Run unit and acceptance suites before submitting. No numeric coverage threshold is configured. +Use Go's `testing` package with Testify assertions. Name tests `Test` and use descriptive table-driven subtests for input variations. Add regression coverage for behavior changes, including error cases. Each acceptance scenario is one `acceptance/tests/.txtar` archive containing setup files, optional `scenario.json`, and named `http//req.http` and `res.http` entries. See `acceptance/README.md` for the format. Run unit and acceptance suites before submitting. No numeric coverage threshold is configured. ## Commit & Pull Request Guidelines diff --git a/acceptance/README.md b/acceptance/README.md index 4f9e869..490a23d 100644 --- a/acceptance/README.md +++ b/acceptance/README.md @@ -1,56 +1,69 @@ # Acceptance tests -Run `make acceptance` from the repository root. This builds the binary before -testing it as a separate process. Scenarios run concurrently without external -network services. - -Each directory in `tests/` contains: - -- `config.txtar`: files to extract into an isolated temporary working directory. - Paths are relative to that directory. The archive can contain only a comment - when a startup error needs no files. -- `http/*.txtar`: a `req.http` and `res.http` pair per exchange. These run in - filename order within a scenario. Expected responses include all headers - except `Date`, which the harness removes. Redirects are asserted directly. -- Optional `scenario.json`: startup arguments and additional expectations. - -Without `scenario.json`, Killgrave starts with `--imposters /imposters` -and an available port. If `args` is present, it replaces those default arguments. -Arguments and extracted file contents support `${PORT}` and `${UPSTREAM}` -substitution. Arguments run directly without a shell. - -For example, to exercise configuration from a different directory: - -```json -{ - "args": ["--config", "settings/config.yml"] -} +Run `make acceptance` from the repository root. It builds Killgrave and runs each +`tests/.txtar` against a separate process with an isolated working +directory and port. No external services are needed. + +Keep each scenario in one archive containing its setup and named HTTP steps: + +```text +-- imposters/routes.imp.json -- +[{"request":{"method":"GET","endpoint":"/example"},"response":{"status":200,"headers":{"Content-Type":"text/plain"},"body":"example\n"}}] +-- http/matched/req.http -- +GET /example HTTP/1.1 + +-- http/matched/res.http -- +HTTP/1.1 200 OK +Content-Length: 8 +Content-Type: text/plain + + +example ``` -The extracted config must include `port: ${PORT}`. Its imposter path should be -relative to the config's directory, rather than the process working directory. -The `config_precedence` scenario records the current behavior: config-file host, -port, and imposter settings take precedence over the corresponding flags. +Each `http//` group is a named subtest containing `req.http` and `res.http`. +Steps run in archive order, so response sequences are visible together without +numbering separate files. Scenarios run concurrently. Expected responses include +all headers except `Date`; redirects are checked directly. -Other settings: +All entries outside `http/`, except `scenario.json`, are extracted as setup files. +By default Killgrave uses the extracted `imposters` directory and an allocated +port. An optional `scenario.json` entry can replace startup arguments: + +```text +-- scenario.json -- +{"args":["--config","settings/config.yml"]} +-- settings/config.yml -- +host: localhost +port: ${PORT} +imposters_path: imposters +``` + +Arguments and setup files support `${PORT}` and `${UPSTREAM}` substitution. +Arguments run directly without a shell. Config-file imposter paths resolve +relative to the config file; `config_precedence.txtar` records precedence over +host, port, and imposter flags. + +Optional `scenario.json` settings: | Setting | Meaning | | --- | --- | -| `error` | Expected diagnostic substring; require an unsuccessful exit within five seconds. No HTTP fixtures are used. | +| `args` | Replace the default startup arguments. | +| `error` | Require an unsuccessful exit and this diagnostic substring. No HTTP steps are needed. | | `occupyPort` | Hold the allocated port open to exercise bind failure. | -| `upstreamCalls` | Start a local upstream and assert this exact request count. Zero also verifies that traffic stays local. | -| `upstreamDown` | Close that upstream before launching Killgrave to exercise a connection failure. | +| `upstreamCalls` | Start a local upstream and assert this exact request count, including zero. | +| `upstreamDown` | Close that upstream before startup to exercise a connection failure. | -The upstream returns status 202, `X-Upstream: yes`, and a text body echoing the -method, request URI, `X-Test` header, and request body. Proxy fixtures use these -to check forwarding as well as upstream response propagation. Readiness uses a -TCP connection, so it neither calls the upstream nor advances a response sequence. +The upstream returns status 202, `X-Upstream: yes`, and a body echoing the method, +request URI, `X-Test` header, and body. TCP readiness does not call the upstream +or advance response sequences. -HTTP calls time out after three seconds; readiness and startup-error commands -have five-second limits. Cleanup allows three seconds for an interrupt before -killing and reaping the process. Server logs are attached to failed scenarios. +Watcher steps can include `http//write/` entries to atomically +create or replace files before the request. An `http//eventually` entry +containing `true` retries the response check for up to five seconds. Writes happen +once. Use retries only for stateless GET requests without bodies; sequence tests +must check every response without retries. See `watcher_flag.txtar` for an example. -Current groups cover config-file paths and precedence, matching/schema fallback, -proxy modes (including schema-rejected bodies and unavailable upstreams), startup -errors, and the original simple resource example. Malformed imposter handling is -not yet specified by this suite; malformed startup configuration is covered. +HTTP requests time out after three seconds; readiness and startup-error commands +have five-second limits. Cleanup allows three seconds for an interrupt before +killing and reaping the process. Failed scenarios include server logs. diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index e21081a..3e9d9e0 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -27,8 +27,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/tools/txtar" - - "github.com/friendsofgo/killgrave/acceptance/utils/network" ) const ( @@ -46,29 +44,13 @@ const ( // Test is the entry point for the acceptance tests. func Test(t *testing.T) { - // First of all, we extract the Killgrave version by running `killgrave version`. - // This is useful, not only to write a log that can serve as metadata for the test results, - // but also to ensure that the Killgrave binary is available. + // Verify the built binary before starting the scenarios. version, err := extractKillgraveVersion(t) require.NoError(t, err, "build Killgrave with make build before running acceptance tests") t.Logf("Running acceptance tests with Killgrave version: %s", version) - // Once we know the Killgrave binary is available, proceed with the acceptance tests. - // The first step is to collect all test cases from the `tests` directory. For each test: - // - // 0. The test case self-contain on each directory, which name is used as the test name. - // 1. Requires a `config.txtar` file at the root level of the test case directory, - // it is used to initialize a file system with all the configuration-related files, - // which not only includes the Killgrave configuration file but also the imposters. - // 2. Uses optional scenario.json startup settings, or the default imposter flags. - // Startup-error scenarios assert an unsuccessful exit instead of sending HTTP. - // Each server runs on an available port so scenarios can run concurrently. - // 3. Requires an `http` directory which contains a set of request and response pairs. - // Each pair is defined by two files: `req.http` and `res.http`, where the request - // is the HTTP request that will be performed as part of the test, and the response - // is the response expected from Killgrave (which will be asserted). - // Each pair is considered one of the test cases that compose the acceptance test, - // defined by the aforementioned parent directory. + // Each archive contains setup files, optional startup settings, and named + // HTTP steps. Scenarios run concurrently; steps within a scenario run in order. tcs := collectTestCases(t) for _, tc := range tcs { @@ -77,9 +59,8 @@ func Test(t *testing.T) { t.Parallel() // 1. Create a temporary directory with the configuration files. - opts := readScenario(t, tc.path) - port, err := network.AnyAvailablePort() - require.NoError(t, err) + opts := readScenario(t, tc.files) + port := availablePort(t) var calls atomic.Int32 upstreamURL := "" if opts.UpstreamCalls != nil { @@ -129,20 +110,14 @@ func Test(t *testing.T) { // 3. Collect the request and response pairs // and iterate over them to perform the tests. - rrs := collectRequestResponses(t, tc.path) + rrs := collectRequestResponses(t, tc.files) for _, rr := range rrs { rr := rr t.Run(rr.name, func(t *testing.T) { // Override the address rr.overrideAddress(address) - // Send the request - res, err := client.Do(rr.req) - require.NoError(t, err) - t.Cleanup(func() { res.Body.Close() }) - - // Assert the res - rr.assertResponse(t, res) + rr.run(t, client, path) }) } if opts.UpstreamCalls != nil { @@ -152,35 +127,43 @@ func Test(t *testing.T) { } } -// testCase represents a test case to be run. -// It is defined by the name of the test case and the path -// to the directory where the testCase files live in. +// availablePort asks the OS for a port, then releases it for the child process. +func availablePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", ":0") + require.NoError(t, err) + port := listener.Addr().(*net.TCPAddr).Port + require.NoError(t, listener.Close()) + return port +} + +// testCase holds one scenario archive: setup files, startup options, and HTTP steps. type testCase struct { - name string - path string + name string + files []txtar.File } -// collectTestCases walks over the `tests` directory and -// constructs all the testCase's from the directories found. +// collectTestCases reads one archive per scenario from the tests directory. func collectTestCases(t *testing.T) []testCase { + t.Helper() var tcs []testCase - cwd, err := os.Getwd() - require.NoError(t, err) - - testsDir := filepath.Join(cwd, testsDir) entries, err := os.ReadDir(testsDir) require.NoError(t, err) for _, entry := range entries { - if entry.IsDir() { - tcs = append(tcs, testCase{ - name: entry.Name(), - path: filepath.Join(testsDir, entry.Name()), - }) + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".txtar") { + continue } + data, err := os.ReadFile(filepath.Join(testsDir, entry.Name())) + require.NoError(t, err) + tcs = append(tcs, testCase{ + name: strings.TrimSuffix(entry.Name(), ".txtar"), + files: txtar.Parse(data).Files, + }) } + require.NotEmpty(t, tcs, "no acceptance scenarios found") return tcs } @@ -190,9 +173,11 @@ func collectTestCases(t *testing.T) []testCase { // - the request to be sent to Killgrave, as *http.Request. // - the expected response from Killgrave, as []byte. type reqRes struct { - name string - req *http.Request - res []byte + name string + req *http.Request + res []byte + writes []txtar.File + eventually bool } // overrideAddress changes the request's URL to use the provided address. @@ -203,17 +188,52 @@ func (rr reqRes) overrideAddress(address string) { rr.req.URL.Host = address } -// assertResponse is a self-contained function that can be used to assert -// that the response received from Killgrave matches the expected response. -// -// It builds the response string from the response object, and then it -// compares it with the expected response (from the test definition). -func (rr reqRes) assertResponse(t *testing.T, response *http.Response) { +// run applies file changes once, then checks the response. Only stateless GET +// exchanges may opt into retries while waiting for a watcher reload. +func (rr reqRes) run(t *testing.T, client *http.Client, dir string) { t.Helper() + for _, file := range rr.writes { + require.True(t, filepath.IsLocal(file.Name), "write path must be relative: %s", file.Name) + target := filepath.Join(dir, file.Name) + require.NoError(t, os.MkdirAll(filepath.Dir(target), 0o755)) + // Publish a complete file so the watcher cannot load partially written JSON. + tmp, err := os.CreateTemp(filepath.Dir(target), ".acceptance-*") + require.NoError(t, err) + _, writeErr := tmp.Write(file.Data) + closeErr := tmp.Close() + require.NoError(t, writeErr) + require.NoError(t, closeErr) + require.NoError(t, os.Rename(tmp.Name(), target)) + } + if rr.eventually { + require.Equal(t, http.MethodGet, rr.req.Method, "eventually is only for stateless GET requests") + require.True(t, rr.req.Body == nil || rr.req.Body == http.NoBody, "eventually requests cannot have a body") + require.EventuallyWithT(t, func(c *assert.CollectT) { + res, err := client.Do(rr.req.Clone(context.Background())) + if !assert.NoError(c, err) { + return + } + actual, err := formatResponse(res) + if assert.NoError(c, err) { + assert.Equal(c, string(rr.res), actual) + } + }, 5*time.Second, 50*time.Millisecond) + return + } + res, err := client.Do(rr.req) + require.NoError(t, err) + actual, err := formatResponse(res) + require.NoError(t, err) + assert.Equal(t, string(rr.res), actual) +} - // Read the response body +// formatResponse consumes and closes the body on every attempt, including retries. +func formatResponse(response *http.Response) (string, error) { + defer response.Body.Close() body, err := io.ReadAll(response.Body) - require.NoError(t, err) + if err != nil { + return "", err + } // Format the status line statusLine := fmt.Sprintf("HTTP/%d.%d %s", response.ProtoMajor, response.ProtoMinor, response.Status) @@ -224,38 +244,53 @@ func (rr reqRes) assertResponse(t *testing.T, response *http.Response) { // Format the headers var headersBuilder strings.Builder err = response.Header.Write(&headersBuilder) - require.NoError(t, err) + if err != nil { + return "", err + } headers := strings.ReplaceAll(headersBuilder.String(), "\r\n", "\n") // Format the response - res := fmt.Sprintf("%s\n%s\n\n%s", statusLine, headers, body) - assert.Equal(t, string(rr.res), res) + return fmt.Sprintf("%s\n%s\n\n%s", statusLine, headers, body), nil } -// collectRequestResponses walks over the `http` directory of the test case -// and collects all the reqRes pairs. In other words, it collects all the test cases. -func collectRequestResponses(t *testing.T, path string) (rrs []reqRes) { - httpDir := filepath.Join(path, "http") - entries, err := os.ReadDir(httpDir) - require.NoError(t, err) - - for _, entry := range entries { - if entry.IsDir() { +// collectRequestResponses groups http// entries, preserving archive order. +func collectRequestResponses(t *testing.T, files []txtar.File) (rrs []reqRes) { + t.Helper() + var order []string + steps := make(map[string][]txtar.File) + for _, file := range files { + rel, ok := strings.CutPrefix(file.Name, "http/") + if !ok { continue } - - rrFilePath := filepath.Join(httpDir, entry.Name()) - - contents, err := os.ReadFile(rrFilePath) - require.NoError(t, err) - - archive := txtar.Parse(contents) - - rrs = append(rrs, reqRes{ - name: entry.Name(), - req: readRequest(t, find(archive.Files, "req.http")), - res: find(archive.Files, "res.http"), - }) + name, part, ok := strings.Cut(rel, "/") + require.True(t, ok && name != "", "expected http//: %s", file.Name) + if _, exists := steps[name]; !exists { + order = append(order, name) + } + steps[name] = append(steps[name], txtar.File{Name: part, Data: file.Data}) + } + require.NotEmpty(t, order, "scenario has no HTTP exchanges") + for _, name := range order { + entries := steps[name] + require.NotEmpty(t, find(entries, "req.http"), "missing request for %s", name) + require.NotEmpty(t, find(entries, "res.http"), "missing response for %s", name) + rr := reqRes{ + name: name, + req: readRequest(t, find(entries, "req.http")), + res: find(entries, "res.http"), + } + for _, file := range entries { + if name, ok := strings.CutPrefix(file.Name, "write/"); ok { + rr.writes = append(rr.writes, txtar.File{Name: name, Data: file.Data}) + } + if file.Name == "eventually" { + eventually, err := strconv.ParseBool(strings.TrimSpace(string(file.Data))) + require.NoError(t, err) + rr.eventually = eventually + } + } + rrs = append(rrs, rr) } return @@ -285,25 +320,17 @@ func readRequest(t *testing.T, raw []byte) *http.Request { return req } -// createTmpCfgDir creates a temporary directory with the configuration files defined -// in the `config.txtar` file, replicating the structure to be used by Killgrave, when executed. -// -// We follow this approach because this way we can test the application assuming the binary -// already exists, so these tests can be run with a recently generated binary (e.g. a release). -// -// Additionally, in the future we might explore ways to reuse this setup to run these tests -// as "integration tests", so faking using a fake, likely in-memory, file system but directly -// calling app.Run(). +// createTmpCfgDir extracts setup files into the process working directory. +// Scenario metadata and HTTP steps stay in the harness. func createTmpCfgDir(t *testing.T, tc testCase, replace *strings.Replacer) string { - // First, we read the `config.txtar` file and initialize a txtar.Archive with its contents. + t.Helper() tmpCfgDir := t.TempDir() - cfgFilePath := filepath.Join(tc.path, "config.txtar") - contents, err := os.ReadFile(cfgFilePath) - require.NoError(t, err) - archive := txtar.Parse(contents) - // Then, we create the temporary directory and write the files. - for _, f := range archive.Files { + for _, f := range tc.files { + if f.Name == "scenario.json" || strings.HasPrefix(f.Name, "http/") { + continue + } + require.True(t, filepath.IsLocal(f.Name), "setup path must be relative: %s", f.Name) filePath := filepath.Join(tmpCfgDir, f.Name) fileDir := filepath.Dir(filePath) @@ -327,13 +354,12 @@ type scenario struct { UpstreamDown bool `json:"upstreamDown"` } -func readScenario(t *testing.T, path string) scenario { +func readScenario(t *testing.T, files []txtar.File) scenario { t.Helper() - data, err := os.ReadFile(filepath.Join(path, "scenario.json")) - if errors.Is(err, os.ErrNotExist) { + data := find(files, "scenario.json") + if data == nil { return scenario{} } - require.NoError(t, err) var opts scenario decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() diff --git a/acceptance/tests/config_file/config.txtar b/acceptance/tests/config_file.txtar similarity index 59% rename from acceptance/tests/config_file/config.txtar rename to acceptance/tests/config_file.txtar index 0af5ea1..6a94284 100644 --- a/acceptance/tests/config_file/config.txtar +++ b/acceptance/tests/config_file.txtar @@ -6,3 +6,21 @@ imposters_path: imposters [{"request": {"method": "GET", "endpoint": "/configured"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "bodyFile": "responses/body.txt"}}] -- settings/imposters/nested/responses/body.txt -- configured +-- scenario.json -- +{ + "args": [ + "--config", + "settings/config.yml" + ] +} +-- http/configured/req.http -- +GET /configured HTTP/1.1 + + +-- http/configured/res.http -- +HTTP/1.1 200 OK +Content-Length: 11 +Content-Type: text/plain + + +configured diff --git a/acceptance/tests/config_file/http/configured.txtar b/acceptance/tests/config_file/http/configured.txtar deleted file mode 100644 index 114978e..0000000 --- a/acceptance/tests/config_file/http/configured.txtar +++ /dev/null @@ -1,11 +0,0 @@ --- req.http -- -GET /configured HTTP/1.1 - - --- res.http -- -HTTP/1.1 200 OK -Content-Length: 11 -Content-Type: text/plain - - -configured diff --git a/acceptance/tests/config_file/scenario.json b/acceptance/tests/config_file/scenario.json deleted file mode 100644 index f6b3426..0000000 --- a/acceptance/tests/config_file/scenario.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "args": [ - "--config", - "settings/config.yml" - ] -} diff --git a/acceptance/tests/config_precedence.txtar b/acceptance/tests/config_precedence.txtar new file mode 100644 index 0000000..f236f6a --- /dev/null +++ b/acceptance/tests/config_precedence.txtar @@ -0,0 +1,30 @@ +-- settings/config.yml -- +host: 127.0.0.1 +port: ${PORT} +imposters_path: imposters +-- settings/imposters/configured.imp.json -- +[{"request": {"method": "GET", "endpoint": "/configured"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "config wins\n"}}] +-- scenario.json -- +{ + "args": [ + "--config", + "settings/config.yml", + "--port", + "-1", + "--host", + "invalid.invalid", + "--imposters", + "missing" + ] +} +-- http/configured/req.http -- +GET /configured HTTP/1.1 + + +-- http/configured/res.http -- +HTTP/1.1 200 OK +Content-Length: 12 +Content-Type: text/plain + + +config wins diff --git a/acceptance/tests/config_precedence/config.txtar b/acceptance/tests/config_precedence/config.txtar deleted file mode 100644 index dffa748..0000000 --- a/acceptance/tests/config_precedence/config.txtar +++ /dev/null @@ -1,6 +0,0 @@ --- settings/config.yml -- -host: 127.0.0.1 -port: ${PORT} -imposters_path: imposters --- settings/imposters/configured.imp.json -- -[{"request": {"method": "GET", "endpoint": "/configured"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "config wins\n"}}] diff --git a/acceptance/tests/config_precedence/http/configured.txtar b/acceptance/tests/config_precedence/http/configured.txtar deleted file mode 100644 index b056e3e..0000000 --- a/acceptance/tests/config_precedence/http/configured.txtar +++ /dev/null @@ -1,11 +0,0 @@ --- req.http -- -GET /configured HTTP/1.1 - - --- res.http -- -HTTP/1.1 200 OK -Content-Length: 12 -Content-Type: text/plain - - -config wins diff --git a/acceptance/tests/config_precedence/scenario.json b/acceptance/tests/config_precedence/scenario.json deleted file mode 100644 index f7ea922..0000000 --- a/acceptance/tests/config_precedence/scenario.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "args": [ - "--config", - "settings/config.yml", - "--port", - "-1", - "--host", - "invalid.invalid", - "--imposters", - "missing" - ] -} diff --git a/acceptance/tests/cors_configured.txtar b/acceptance/tests/cors_configured.txtar new file mode 100644 index 0000000..14b60d4 --- /dev/null +++ b/acceptance/tests/cors_configured.txtar @@ -0,0 +1,119 @@ +-- config.yml -- +host: localhost +port: ${PORT} +imposters_path: imposters +cors: + origins: ["https://client.example", "https://other.example"] + methods: ["GET", "PUT"] + headers: ["X-Token"] + exposed_headers: ["X-Trace"] + allow_credentials: true +-- imposters/routes.imp.json -- +[{"request": {"method": "GET", "endpoint": "/cors"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "cors\n"}}] +-- scenario.json -- +{ + "args": [ + "--config", + "config.yml" + ] +} +-- http/actual_allowed/req.http -- +GET /cors HTTP/1.1 +Origin: https://client.example + + +-- http/actual_allowed/res.http -- +HTTP/1.1 200 OK +Access-Control-Allow-Credentials: true +Access-Control-Allow-Origin: https://client.example +Access-Control-Expose-Headers: X-Trace +Content-Length: 5 +Content-Type: text/plain +Vary: Origin + + +cors +-- http/actual_no_origin/req.http -- +GET /cors HTTP/1.1 + + +-- http/actual_no_origin/res.http -- +HTTP/1.1 200 OK +Content-Length: 5 +Content-Type: text/plain + + +cors +-- http/actual_wrong_origin/req.http -- +GET /cors HTTP/1.1 +Origin: https://denied.example + + +-- http/actual_wrong_origin/res.http -- +HTTP/1.1 200 OK +Content-Length: 5 +Content-Type: text/plain + + +cors +-- http/preflight_allowed/req.http -- +OPTIONS /cors HTTP/1.1 +Origin: https://client.example +Access-Control-Request-Method: PUT +Access-Control-Request-Headers: x-token + + +-- http/preflight_allowed/res.http -- +HTTP/1.1 200 OK +Access-Control-Allow-Credentials: true +Access-Control-Allow-Headers: X-Token +Access-Control-Allow-Methods: PUT +Access-Control-Allow-Origin: https://client.example +Content-Length: 0 +Vary: Origin + + +-- http/preflight_missing_method/req.http -- +OPTIONS /cors HTTP/1.1 +Origin: https://client.example + + +-- http/preflight_missing_method/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 0 + + +-- http/preflight_wrong_header/req.http -- +OPTIONS /cors HTTP/1.1 +Origin: https://client.example +Access-Control-Request-Method: PUT +Access-Control-Request-Headers: X-Forbidden + + +-- http/preflight_wrong_header/res.http -- +HTTP/1.1 403 Forbidden +Content-Length: 0 + + +-- http/preflight_wrong_method/req.http -- +OPTIONS /cors HTTP/1.1 +Origin: https://client.example +Access-Control-Request-Method: DELETE + + +-- http/preflight_wrong_method/res.http -- +HTTP/1.1 405 Method Not Allowed +Content-Length: 0 + + +-- http/preflight_wrong_origin/req.http -- +OPTIONS /cors HTTP/1.1 +Origin: https://denied.example +Access-Control-Request-Method: PUT + + +-- http/preflight_wrong_origin/res.http -- +HTTP/1.1 200 OK +Content-Length: 0 + + diff --git a/acceptance/tests/cors_defaults.txtar b/acceptance/tests/cors_defaults.txtar new file mode 100644 index 0000000..cb25d97 --- /dev/null +++ b/acceptance/tests/cors_defaults.txtar @@ -0,0 +1,42 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "GET", "endpoint": "/cors"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "cors\n"}}] +-- http/actual_defaults/req.http -- +GET /cors HTTP/1.1 +Origin: https://client.example + + +-- http/actual_defaults/res.http -- +HTTP/1.1 200 OK +Access-Control-Allow-Origin: * +Access-Control-Expose-Headers: Cache-Control,Content-Language,Content-Type,Expires,Last-Modified,Pragma +Content-Length: 5 +Content-Type: text/plain + + +cors +-- http/actual_no_origin/req.http -- +GET /cors HTTP/1.1 + + +-- http/actual_no_origin/res.http -- +HTTP/1.1 200 OK +Content-Length: 5 +Content-Type: text/plain + + +cors +-- http/preflight_default_methods_headers/req.http -- +OPTIONS /cors HTTP/1.1 +Origin: https://client.example +Access-Control-Request-Method: PATCH +Access-Control-Request-Headers: content-type,authorization + + +-- http/preflight_default_methods_headers/res.http -- +HTTP/1.1 200 OK +Access-Control-Allow-Headers: Content-Type,Authorization +Access-Control-Allow-Methods: PATCH +Access-Control-Allow-Origin: * +Content-Length: 0 + + diff --git a/acceptance/tests/matching.txtar b/acceptance/tests/matching.txtar new file mode 100644 index 0000000..07e07a5 --- /dev/null +++ b/acceptance/tests/matching.txtar @@ -0,0 +1,241 @@ +-- imposters/routes.imp.json -- +[ + { + "request": { + "method": "POST", + "endpoint": "/animals", + "schemaFile": "schemas/gopher.json" + }, + "response": { + "status": 201, + "headers": { + "Content-Type": "text/plain" + }, + "body": "gopher\n" + } + }, + { + "request": { + "method": "POST", + "endpoint": "/animals", + "schemaFile": "schemas/cat.json" + }, + "response": { + "status": 202, + "headers": { + "Content-Type": "text/plain" + }, + "body": "cat\n" + } + }, + { + "request": { + "method": "POST", + "endpoint": "/animals" + }, + "response": { + "status": 400, + "headers": { + "Content-Type": "text/plain" + }, + "body": "fallback\n" + } + }, + { + "request": { + "method": "GET", + "endpoint": "/restricted/{id:[0-9]+}", + "headers": { + "X-Key": "^token[a-z]+$" + }, + "params": { + "color": "{color:red|blue}" + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "matched\n" + } + }, + { + "request": { + "method": "GET", + "endpoint": "/restricted/{id}" + }, + "response": { + "status": 418, + "headers": { + "Content-Type": "text/plain" + }, + "body": "fallback\n" + } + } +] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} +-- imposters/schemas/cat.json -- +{"type": "object", "properties": {"type": {"enum": ["cat"]}}, "required": ["type"]} +-- http/matching_missing_header/req.http -- +GET /restricted/42?color=red HTTP/1.1 + + +-- http/matching_missing_header/res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/matching_missing_query/req.http -- +GET /restricted/42 HTTP/1.1 +X-Key: tokenabc + + +-- http/matching_missing_query/res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/matching_valid/req.http -- +GET /restricted/42?color=red HTTP/1.1 +X-Key: tokenabc + + +-- http/matching_valid/res.http -- +HTTP/1.1 200 OK +Content-Length: 8 +Content-Type: text/plain + + +matched +-- http/matching_wrong_header/req.http -- +GET /restricted/42?color=red HTTP/1.1 +X-Key: invalid + + +-- http/matching_wrong_header/res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/matching_wrong_path/req.http -- +GET /restricted/abc?color=red HTTP/1.1 +X-Key: tokenabc + + +-- http/matching_wrong_path/res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/matching_wrong_query/req.http -- +GET /restricted/42?color=green HTTP/1.1 +X-Key: tokenabc + + +-- http/matching_wrong_query/res.http -- +HTTP/1.1 418 I'm a teapot +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/schema_empty/req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 0 + + +-- http/schema_empty/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/schema_invalid/req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 15 + +{"type":"dog"} + +-- http/schema_invalid/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/schema_malformed/req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 8 + +{broken + +-- http/schema_malformed/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/schema_second_schema/req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 15 + +{"type":"cat"} + +-- http/schema_second_schema/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 4 +Content-Type: text/plain + + +cat +-- http/schema_valid/req.http -- +POST /animals HTTP/1.1 +Content-Type: application/json +Content-Length: 18 + +{"type":"gopher"} + +-- http/schema_valid/res.http -- +HTTP/1.1 201 Created +Content-Length: 7 +Content-Type: text/plain + + +gopher +-- http/unknown_route/req.http -- +GET /unknown HTTP/1.1 + + +-- http/unknown_route/res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found +-- http/wrong_method/req.http -- +DELETE /animals HTTP/1.1 + + +-- http/wrong_method/res.http -- +HTTP/1.1 405 Method Not Allowed +Content-Length: 0 + + diff --git a/acceptance/tests/matching/config.txtar b/acceptance/tests/matching/config.txtar deleted file mode 100644 index 7941cad..0000000 --- a/acceptance/tests/matching/config.txtar +++ /dev/null @@ -1,80 +0,0 @@ --- imposters/routes.imp.json -- -[ - { - "request": { - "method": "POST", - "endpoint": "/animals", - "schemaFile": "schemas/gopher.json" - }, - "response": { - "status": 201, - "headers": { - "Content-Type": "text/plain" - }, - "body": "gopher\n" - } - }, - { - "request": { - "method": "POST", - "endpoint": "/animals", - "schemaFile": "schemas/cat.json" - }, - "response": { - "status": 202, - "headers": { - "Content-Type": "text/plain" - }, - "body": "cat\n" - } - }, - { - "request": { - "method": "POST", - "endpoint": "/animals" - }, - "response": { - "status": 400, - "headers": { - "Content-Type": "text/plain" - }, - "body": "fallback\n" - } - }, - { - "request": { - "method": "GET", - "endpoint": "/restricted/{id:[0-9]+}", - "headers": { - "X-Key": "^token[a-z]+$" - }, - "params": { - "color": "{color:red|blue}" - } - }, - "response": { - "status": 200, - "headers": { - "Content-Type": "text/plain" - }, - "body": "matched\n" - } - }, - { - "request": { - "method": "GET", - "endpoint": "/restricted/{id}" - }, - "response": { - "status": 418, - "headers": { - "Content-Type": "text/plain" - }, - "body": "fallback\n" - } - } -] --- imposters/schemas/gopher.json -- -{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} --- imposters/schemas/cat.json -- -{"type": "object", "properties": {"type": {"enum": ["cat"]}}, "required": ["type"]} diff --git a/acceptance/tests/matching/http/matching_missing_header.txtar b/acceptance/tests/matching/http/matching_missing_header.txtar deleted file mode 100644 index effffb0..0000000 --- a/acceptance/tests/matching/http/matching_missing_header.txtar +++ /dev/null @@ -1,11 +0,0 @@ --- req.http -- -GET /restricted/42?color=red HTTP/1.1 - - --- res.http -- -HTTP/1.1 418 I'm a teapot -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/matching_missing_query.txtar b/acceptance/tests/matching/http/matching_missing_query.txtar deleted file mode 100644 index 390e1e9..0000000 --- a/acceptance/tests/matching/http/matching_missing_query.txtar +++ /dev/null @@ -1,12 +0,0 @@ --- req.http -- -GET /restricted/42 HTTP/1.1 -X-Key: tokenabc - - --- res.http -- -HTTP/1.1 418 I'm a teapot -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/matching_valid.txtar b/acceptance/tests/matching/http/matching_valid.txtar deleted file mode 100644 index a7f9422..0000000 --- a/acceptance/tests/matching/http/matching_valid.txtar +++ /dev/null @@ -1,12 +0,0 @@ --- req.http -- -GET /restricted/42?color=red HTTP/1.1 -X-Key: tokenabc - - --- res.http -- -HTTP/1.1 200 OK -Content-Length: 8 -Content-Type: text/plain - - -matched diff --git a/acceptance/tests/matching/http/matching_wrong_header.txtar b/acceptance/tests/matching/http/matching_wrong_header.txtar deleted file mode 100644 index 0e1dc24..0000000 --- a/acceptance/tests/matching/http/matching_wrong_header.txtar +++ /dev/null @@ -1,12 +0,0 @@ --- req.http -- -GET /restricted/42?color=red HTTP/1.1 -X-Key: invalid - - --- res.http -- -HTTP/1.1 418 I'm a teapot -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/matching_wrong_path.txtar b/acceptance/tests/matching/http/matching_wrong_path.txtar deleted file mode 100644 index 1809eb3..0000000 --- a/acceptance/tests/matching/http/matching_wrong_path.txtar +++ /dev/null @@ -1,12 +0,0 @@ --- req.http -- -GET /restricted/abc?color=red HTTP/1.1 -X-Key: tokenabc - - --- res.http -- -HTTP/1.1 418 I'm a teapot -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/matching_wrong_query.txtar b/acceptance/tests/matching/http/matching_wrong_query.txtar deleted file mode 100644 index bdf32d7..0000000 --- a/acceptance/tests/matching/http/matching_wrong_query.txtar +++ /dev/null @@ -1,12 +0,0 @@ --- req.http -- -GET /restricted/42?color=green HTTP/1.1 -X-Key: tokenabc - - --- res.http -- -HTTP/1.1 418 I'm a teapot -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/schema_empty.txtar b/acceptance/tests/matching/http/schema_empty.txtar deleted file mode 100644 index cfcaa36..0000000 --- a/acceptance/tests/matching/http/schema_empty.txtar +++ /dev/null @@ -1,13 +0,0 @@ --- req.http -- -POST /animals HTTP/1.1 -Content-Type: application/json -Content-Length: 0 - - --- res.http -- -HTTP/1.1 400 Bad Request -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/schema_invalid.txtar b/acceptance/tests/matching/http/schema_invalid.txtar deleted file mode 100644 index 8b70a4c..0000000 --- a/acceptance/tests/matching/http/schema_invalid.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /animals HTTP/1.1 -Content-Type: application/json -Content-Length: 15 - -{"type":"dog"} - --- res.http -- -HTTP/1.1 400 Bad Request -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/schema_malformed.txtar b/acceptance/tests/matching/http/schema_malformed.txtar deleted file mode 100644 index 1a2b315..0000000 --- a/acceptance/tests/matching/http/schema_malformed.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /animals HTTP/1.1 -Content-Type: application/json -Content-Length: 8 - -{broken - --- res.http -- -HTTP/1.1 400 Bad Request -Content-Length: 9 -Content-Type: text/plain - - -fallback diff --git a/acceptance/tests/matching/http/schema_second_schema.txtar b/acceptance/tests/matching/http/schema_second_schema.txtar deleted file mode 100644 index 6b0af36..0000000 --- a/acceptance/tests/matching/http/schema_second_schema.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /animals HTTP/1.1 -Content-Type: application/json -Content-Length: 15 - -{"type":"cat"} - --- res.http -- -HTTP/1.1 202 Accepted -Content-Length: 4 -Content-Type: text/plain - - -cat diff --git a/acceptance/tests/matching/http/schema_valid.txtar b/acceptance/tests/matching/http/schema_valid.txtar deleted file mode 100644 index 279e4a2..0000000 --- a/acceptance/tests/matching/http/schema_valid.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /animals HTTP/1.1 -Content-Type: application/json -Content-Length: 18 - -{"type":"gopher"} - --- res.http -- -HTTP/1.1 201 Created -Content-Length: 7 -Content-Type: text/plain - - -gopher diff --git a/acceptance/tests/matching/http/unknown_route.txtar b/acceptance/tests/matching/http/unknown_route.txtar deleted file mode 100644 index 1cb0cbc..0000000 --- a/acceptance/tests/matching/http/unknown_route.txtar +++ /dev/null @@ -1,12 +0,0 @@ --- req.http -- -GET /unknown HTTP/1.1 - - --- res.http -- -HTTP/1.1 404 Not Found -Content-Length: 19 -Content-Type: text/plain; charset=utf-8 -X-Content-Type-Options: nosniff - - -404 page not found diff --git a/acceptance/tests/matching/http/wrong_method.txtar b/acceptance/tests/matching/http/wrong_method.txtar deleted file mode 100644 index fcc21f5..0000000 --- a/acceptance/tests/matching/http/wrong_method.txtar +++ /dev/null @@ -1,9 +0,0 @@ --- req.http -- -DELETE /animals HTTP/1.1 - - --- res.http -- -HTTP/1.1 405 Method Not Allowed -Content-Length: 0 - - diff --git a/acceptance/tests/proxy_all.txtar b/acceptance/tests/proxy_all.txtar new file mode 100644 index 0000000..d4fde08 --- /dev/null +++ b/acceptance/tests/proxy_all.txtar @@ -0,0 +1,72 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} +-- scenario.json -- +{ + "upstreamCalls": 3, + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--proxy-mode", + "all", + "--proxy-url", + "${UPSTREAM}" + ] +} +-- http/missing/req.http -- +POST /upstream/items?color=red&n=2 HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 8 + +payload + +-- http/missing/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 61 +Content-Type: text/plain +X-Upstream: yes + + +POST /upstream/items?color=red&n=2 +X-Test: preserved +payload +-- http/mock/req.http -- +POST /mock HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 8 + +payload + +-- http/mock/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 37 +Content-Type: text/plain +X-Upstream: yes + + +POST /mock +X-Test: preserved +payload +-- http/schema_rejected/req.http -- +POST /submit HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 15 + +{"type":"cat"} + +-- http/schema_rejected/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 46 +Content-Type: text/plain +X-Upstream: yes + + +POST /submit +X-Test: preserved +{"type":"cat"} diff --git a/acceptance/tests/proxy_all/config.txtar b/acceptance/tests/proxy_all/config.txtar deleted file mode 100644 index 8986c17..0000000 --- a/acceptance/tests/proxy_all/config.txtar +++ /dev/null @@ -1,4 +0,0 @@ --- imposters/routes.imp.json -- -[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] --- imposters/schemas/gopher.json -- -{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} diff --git a/acceptance/tests/proxy_all/http/missing.txtar b/acceptance/tests/proxy_all/http/missing.txtar deleted file mode 100644 index 9223272..0000000 --- a/acceptance/tests/proxy_all/http/missing.txtar +++ /dev/null @@ -1,18 +0,0 @@ --- req.http -- -POST /upstream/items?color=red&n=2 HTTP/1.1 -X-Test: preserved -Content-Type: application/json -Content-Length: 8 - -payload - --- res.http -- -HTTP/1.1 202 Accepted -Content-Length: 61 -Content-Type: text/plain -X-Upstream: yes - - -POST /upstream/items?color=red&n=2 -X-Test: preserved -payload diff --git a/acceptance/tests/proxy_all/http/mock.txtar b/acceptance/tests/proxy_all/http/mock.txtar deleted file mode 100644 index c86ba74..0000000 --- a/acceptance/tests/proxy_all/http/mock.txtar +++ /dev/null @@ -1,18 +0,0 @@ --- req.http -- -POST /mock HTTP/1.1 -X-Test: preserved -Content-Type: application/json -Content-Length: 8 - -payload - --- res.http -- -HTTP/1.1 202 Accepted -Content-Length: 37 -Content-Type: text/plain -X-Upstream: yes - - -POST /mock -X-Test: preserved -payload diff --git a/acceptance/tests/proxy_all/http/schema_rejected.txtar b/acceptance/tests/proxy_all/http/schema_rejected.txtar deleted file mode 100644 index b864b3c..0000000 --- a/acceptance/tests/proxy_all/http/schema_rejected.txtar +++ /dev/null @@ -1,18 +0,0 @@ --- req.http -- -POST /submit HTTP/1.1 -X-Test: preserved -Content-Type: application/json -Content-Length: 15 - -{"type":"cat"} - --- res.http -- -HTTP/1.1 202 Accepted -Content-Length: 46 -Content-Type: text/plain -X-Upstream: yes - - -POST /submit -X-Test: preserved -{"type":"cat"} diff --git a/acceptance/tests/proxy_all/scenario.json b/acceptance/tests/proxy_all/scenario.json deleted file mode 100644 index d74c08c..0000000 --- a/acceptance/tests/proxy_all/scenario.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "upstreamCalls": 3, - "args": [ - "--port", - "${PORT}", - "--imposters", - "imposters", - "--proxy-mode", - "all", - "--proxy-url", - "${UPSTREAM}" - ] -} diff --git a/acceptance/tests/proxy_missing.txtar b/acceptance/tests/proxy_missing.txtar new file mode 100644 index 0000000..ffc62ce --- /dev/null +++ b/acceptance/tests/proxy_missing.txtar @@ -0,0 +1,69 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} +-- config.yml -- +host: localhost +port: ${PORT} +imposters_path: imposters +proxy: + mode: missing + url: ${UPSTREAM} +-- scenario.json -- +{ + "upstreamCalls": 2, + "args": [ + "--config", + "config.yml" + ] +} +-- http/missing/req.http -- +POST /upstream/items?color=red&n=2 HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 8 + +payload + +-- http/missing/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 61 +Content-Type: text/plain +X-Upstream: yes + + +POST /upstream/items?color=red&n=2 +X-Test: preserved +payload +-- http/mock/req.http -- +POST /mock HTTP/1.1 +X-Test: preserved +Content-Length: 8 + +payload + +-- http/mock/res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +mocked +-- http/schema_rejected/req.http -- +POST /submit HTTP/1.1 +X-Test: preserved +Content-Type: application/json +Content-Length: 15 + +{"type":"cat"} + +-- http/schema_rejected/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 46 +Content-Type: text/plain +X-Upstream: yes + + +POST /submit +X-Test: preserved +{"type":"cat"} diff --git a/acceptance/tests/proxy_missing/config.txtar b/acceptance/tests/proxy_missing/config.txtar deleted file mode 100644 index 3784ddb..0000000 --- a/acceptance/tests/proxy_missing/config.txtar +++ /dev/null @@ -1,11 +0,0 @@ --- imposters/routes.imp.json -- -[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] --- imposters/schemas/gopher.json -- -{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} --- config.yml -- -host: localhost -port: ${PORT} -imposters_path: imposters -proxy: - mode: missing - url: ${UPSTREAM} diff --git a/acceptance/tests/proxy_missing/http/missing.txtar b/acceptance/tests/proxy_missing/http/missing.txtar deleted file mode 100644 index 9223272..0000000 --- a/acceptance/tests/proxy_missing/http/missing.txtar +++ /dev/null @@ -1,18 +0,0 @@ --- req.http -- -POST /upstream/items?color=red&n=2 HTTP/1.1 -X-Test: preserved -Content-Type: application/json -Content-Length: 8 - -payload - --- res.http -- -HTTP/1.1 202 Accepted -Content-Length: 61 -Content-Type: text/plain -X-Upstream: yes - - -POST /upstream/items?color=red&n=2 -X-Test: preserved -payload diff --git a/acceptance/tests/proxy_missing/http/mock.txtar b/acceptance/tests/proxy_missing/http/mock.txtar deleted file mode 100644 index e652d9f..0000000 --- a/acceptance/tests/proxy_missing/http/mock.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /mock HTTP/1.1 -X-Test: preserved -Content-Length: 8 - -payload - --- res.http -- -HTTP/1.1 200 OK -Content-Length: 7 -Content-Type: text/plain - - -mocked diff --git a/acceptance/tests/proxy_missing/http/schema_rejected.txtar b/acceptance/tests/proxy_missing/http/schema_rejected.txtar deleted file mode 100644 index b864b3c..0000000 --- a/acceptance/tests/proxy_missing/http/schema_rejected.txtar +++ /dev/null @@ -1,18 +0,0 @@ --- req.http -- -POST /submit HTTP/1.1 -X-Test: preserved -Content-Type: application/json -Content-Length: 15 - -{"type":"cat"} - --- res.http -- -HTTP/1.1 202 Accepted -Content-Length: 46 -Content-Type: text/plain -X-Upstream: yes - - -POST /submit -X-Test: preserved -{"type":"cat"} diff --git a/acceptance/tests/proxy_missing/scenario.json b/acceptance/tests/proxy_missing/scenario.json deleted file mode 100644 index 203a4ed..0000000 --- a/acceptance/tests/proxy_missing/scenario.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "upstreamCalls": 2, - "args": [ - "--config", - "config.yml" - ] -} diff --git a/acceptance/tests/proxy_none.txtar b/acceptance/tests/proxy_none.txtar new file mode 100644 index 0000000..320aa23 --- /dev/null +++ b/acceptance/tests/proxy_none.txtar @@ -0,0 +1,60 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] +-- imposters/schemas/gopher.json -- +{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} +-- scenario.json -- +{ + "upstreamCalls": 0, + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--proxy-mode", + "none", + "--proxy-url", + "${UPSTREAM}" + ] +} +-- http/missing/req.http -- +POST /upstream/items?color=red&n=2 HTTP/1.1 +Content-Length: 8 + +payload + +-- http/missing/res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found +-- http/mock/req.http -- +POST /mock HTTP/1.1 +X-Test: preserved +Content-Length: 8 + +payload + +-- http/mock/res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +mocked +-- http/schema_rejected/req.http -- +POST /submit HTTP/1.1 +Content-Length: 15 + +{"type":"cat"} + +-- http/schema_rejected/res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found diff --git a/acceptance/tests/proxy_none/config.txtar b/acceptance/tests/proxy_none/config.txtar deleted file mode 100644 index 8986c17..0000000 --- a/acceptance/tests/proxy_none/config.txtar +++ /dev/null @@ -1,4 +0,0 @@ --- imposters/routes.imp.json -- -[{"request": {"method": "POST", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}, {"request": {"method": "POST", "endpoint": "/submit", "schemaFile": "schemas/gopher.json"}, "response": {"status": 201, "headers": {"Content-Type": "text/plain"}, "body": "gopher\n"}}] --- imposters/schemas/gopher.json -- -{"type": "object", "properties": {"type": {"enum": ["gopher"]}}, "required": ["type"]} diff --git a/acceptance/tests/proxy_none/http/missing.txtar b/acceptance/tests/proxy_none/http/missing.txtar deleted file mode 100644 index 43865f2..0000000 --- a/acceptance/tests/proxy_none/http/missing.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /upstream/items?color=red&n=2 HTTP/1.1 -Content-Length: 8 - -payload - --- res.http -- -HTTP/1.1 404 Not Found -Content-Length: 19 -Content-Type: text/plain; charset=utf-8 -X-Content-Type-Options: nosniff - - -404 page not found diff --git a/acceptance/tests/proxy_none/http/mock.txtar b/acceptance/tests/proxy_none/http/mock.txtar deleted file mode 100644 index e652d9f..0000000 --- a/acceptance/tests/proxy_none/http/mock.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /mock HTTP/1.1 -X-Test: preserved -Content-Length: 8 - -payload - --- res.http -- -HTTP/1.1 200 OK -Content-Length: 7 -Content-Type: text/plain - - -mocked diff --git a/acceptance/tests/proxy_none/http/schema_rejected.txtar b/acceptance/tests/proxy_none/http/schema_rejected.txtar deleted file mode 100644 index 61a0d46..0000000 --- a/acceptance/tests/proxy_none/http/schema_rejected.txtar +++ /dev/null @@ -1,14 +0,0 @@ --- req.http -- -POST /submit HTTP/1.1 -Content-Length: 15 - -{"type":"cat"} - --- res.http -- -HTTP/1.1 404 Not Found -Content-Length: 19 -Content-Type: text/plain; charset=utf-8 -X-Content-Type-Options: nosniff - - -404 page not found diff --git a/acceptance/tests/proxy_none/scenario.json b/acceptance/tests/proxy_none/scenario.json deleted file mode 100644 index 9f77b90..0000000 --- a/acceptance/tests/proxy_none/scenario.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "upstreamCalls": 0, - "args": [ - "--port", - "${PORT}", - "--imposters", - "imposters", - "--proxy-mode", - "none", - "--proxy-url", - "${UPSTREAM}" - ] -} diff --git a/acceptance/tests/proxy_unavailable.txtar b/acceptance/tests/proxy_unavailable.txtar new file mode 100644 index 0000000..b71d7c7 --- /dev/null +++ b/acceptance/tests/proxy_unavailable.txtar @@ -0,0 +1,26 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "GET", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mock\n"}}] +-- scenario.json -- +{ + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--proxy-mode", + "all", + "--proxy-url", + "${UPSTREAM}" + ], + "upstreamCalls": 0, + "upstreamDown": true +} +-- http/unavailable/req.http -- +GET /missing HTTP/1.1 + + +-- http/unavailable/res.http -- +HTTP/1.1 502 Bad Gateway +Content-Length: 0 + + diff --git a/acceptance/tests/proxy_unavailable/config.txtar b/acceptance/tests/proxy_unavailable/config.txtar deleted file mode 100644 index 8094baf..0000000 --- a/acceptance/tests/proxy_unavailable/config.txtar +++ /dev/null @@ -1,2 +0,0 @@ --- imposters/routes.imp.json -- -[{"request": {"method": "GET", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mock\n"}}] diff --git a/acceptance/tests/proxy_unavailable/http/unavailable.txtar b/acceptance/tests/proxy_unavailable/http/unavailable.txtar deleted file mode 100644 index ba90bdd..0000000 --- a/acceptance/tests/proxy_unavailable/http/unavailable.txtar +++ /dev/null @@ -1,9 +0,0 @@ --- req.http -- -GET /missing HTTP/1.1 - - --- res.http -- -HTTP/1.1 502 Bad Gateway -Content-Length: 0 - - diff --git a/acceptance/tests/proxy_unavailable/scenario.json b/acceptance/tests/proxy_unavailable/scenario.json deleted file mode 100644 index 6fab2a5..0000000 --- a/acceptance/tests/proxy_unavailable/scenario.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "args": [ - "--port", - "${PORT}", - "--imposters", - "imposters", - "--proxy-mode", - "all", - "--proxy-url", - "${UPSTREAM}" - ], - "upstreamCalls": 0, - "upstreamDown": true -} diff --git a/acceptance/tests/response_sequences.txtar b/acceptance/tests/response_sequences.txtar new file mode 100644 index 0000000..408c59d --- /dev/null +++ b/acceptance/tests/response_sequences.txtar @@ -0,0 +1,239 @@ +-- imposters/json.imp.json -- +[ + { + "request": { + "method": "POST", + "endpoint": "/json/a" + }, + "response": [ + { + "status": 201, + "headers": { + "Content-Type": "text/plain" + }, + "body": "json first\n" + }, + { + "status": 409, + "headers": { + "Content-Type": "text/plain" + }, + "body": "json second\n" + } + ] + }, + { + "request": { + "method": "POST", + "endpoint": "/json/b" + }, + "response": [ + { + "status": 202, + "headers": { + "Content-Type": "text/plain" + }, + "body": "json b first\n" + }, + { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "json b second\n" + } + ] + }, + { + "request": { + "method": "GET", + "endpoint": "/single" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "single\n" + } + } +] +-- imposters/yaml.imp.yaml -- +- request: + method: POST + endpoint: /yaml/a + response: + - status: 201 + headers: + Content-Type: text/plain + body: "yaml first\n" + - status: 409 + headers: + Content-Type: text/plain + body: "yaml second\n" +- request: + method: POST + endpoint: /yaml/b + response: + - status: 202 + headers: + Content-Type: text/plain + body: "yaml b first\n" + - status: 200 + headers: + Content-Type: text/plain + body: "yaml b second\n" +-- http/01_json_a/req.http -- +POST /json/a HTTP/1.1 + + +-- http/01_json_a/res.http -- +HTTP/1.1 201 Created +Content-Length: 11 +Content-Type: text/plain + + +json first +-- http/02_json_b/req.http -- +POST /json/b HTTP/1.1 + + +-- http/02_json_b/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 13 +Content-Type: text/plain + + +json b first +-- http/03_json_a/req.http -- +POST /json/a HTTP/1.1 + + +-- http/03_json_a/res.http -- +HTTP/1.1 409 Conflict +Content-Length: 12 +Content-Type: text/plain + + +json second +-- http/04_json_a/req.http -- +POST /json/a HTTP/1.1 + + +-- http/04_json_a/res.http -- +HTTP/1.1 201 Created +Content-Length: 11 +Content-Type: text/plain + + +json first +-- http/05_json_b/req.http -- +POST /json/b HTTP/1.1 + + +-- http/05_json_b/res.http -- +HTTP/1.1 200 OK +Content-Length: 14 +Content-Type: text/plain + + +json b second +-- http/06_json_b/req.http -- +POST /json/b HTTP/1.1 + + +-- http/06_json_b/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 13 +Content-Type: text/plain + + +json b first +-- http/07_yaml_a/req.http -- +POST /yaml/a HTTP/1.1 + + +-- http/07_yaml_a/res.http -- +HTTP/1.1 201 Created +Content-Length: 11 +Content-Type: text/plain + + +yaml first +-- http/08_yaml_b/req.http -- +POST /yaml/b HTTP/1.1 + + +-- http/08_yaml_b/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 13 +Content-Type: text/plain + + +yaml b first +-- http/09_yaml_a/req.http -- +POST /yaml/a HTTP/1.1 + + +-- http/09_yaml_a/res.http -- +HTTP/1.1 409 Conflict +Content-Length: 12 +Content-Type: text/plain + + +yaml second +-- http/10_yaml_a/req.http -- +POST /yaml/a HTTP/1.1 + + +-- http/10_yaml_a/res.http -- +HTTP/1.1 201 Created +Content-Length: 11 +Content-Type: text/plain + + +yaml first +-- http/11_yaml_b/req.http -- +POST /yaml/b HTTP/1.1 + + +-- http/11_yaml_b/res.http -- +HTTP/1.1 200 OK +Content-Length: 14 +Content-Type: text/plain + + +yaml b second +-- http/12_yaml_b/req.http -- +POST /yaml/b HTTP/1.1 + + +-- http/12_yaml_b/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 13 +Content-Type: text/plain + + +yaml b first +-- http/20_single_0/req.http -- +GET /single HTTP/1.1 + + +-- http/20_single_0/res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +single +-- http/20_single_1/req.http -- +GET /single HTTP/1.1 + + +-- http/20_single_1/res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +single diff --git a/acceptance/tests/simple/config.txtar b/acceptance/tests/simple.txtar similarity index 57% rename from acceptance/tests/simple/config.txtar rename to acceptance/tests/simple.txtar index a857747..7f0e8e7 100644 --- a/acceptance/tests/simple/config.txtar +++ b/acceptance/tests/simple.txtar @@ -62,4 +62,58 @@ "age": 54 } } -} \ No newline at end of file +} +-- http/create/req.http -- +POST /gophers?gopherColor=red HTTP/1.1 +Content-Length: 95 +Content-Type: application/json + +{"data": {"type": "gophers", "attributes": {"name": "Zebediah", "color": "Purple", "age": 54}}} + +-- http/create/res.http -- +HTTP/1.1 201 Created +Content-Length: 214 +Content-Type: application/json + + +{ + "data": { + "type": "gophers", + "id": "01D8EMQ185CA8PRGE20DKZTGSR", + "attributes": { + "name": "Zebediah", + "color": "Purple", + "age": 54 + } + } +} +-- http/fetchExisting/req.http -- +GET /gophers/01D8EMQ185CA8PRGE20DKZTGSR HTTP/1.1 +Content-Type: application/json + +-- http/fetchExisting/res.http -- +HTTP/1.1 200 OK +Content-Length: 214 +Content-Type: application/json + + +{ + "data": { + "type": "gophers", + "id": "01D8EMQ185CA8PRGE20DKZTGSR", + "attributes": { + "name": "Zebediah", + "color": "Purple", + "age": 54 + } + } +} +-- http/fetchNotFound/req.http -- +GET /gophers/01D8EMQ185CA8PRGE20DKZT404 HTTP/1.1 +Content-Type: application/json + +-- http/fetchNotFound/res.http -- +HTTP/1.1 404 Not Found +Content-Length: 0 + + diff --git a/acceptance/tests/simple/http/create.txtar b/acceptance/tests/simple/http/create.txtar deleted file mode 100644 index 4444642..0000000 --- a/acceptance/tests/simple/http/create.txtar +++ /dev/null @@ -1,24 +0,0 @@ --- req.http -- -POST /gophers?gopherColor=red HTTP/1.1 -Content-Length: 95 -Content-Type: application/json - -{"data": {"type": "gophers", "attributes": {"name": "Zebediah", "color": "Purple", "age": 54}}} - --- res.http -- -HTTP/1.1 201 Created -Content-Length: 214 -Content-Type: application/json - - -{ - "data": { - "type": "gophers", - "id": "01D8EMQ185CA8PRGE20DKZTGSR", - "attributes": { - "name": "Zebediah", - "color": "Purple", - "age": 54 - } - } -} diff --git a/acceptance/tests/simple/http/fetchExisting.txtar b/acceptance/tests/simple/http/fetchExisting.txtar deleted file mode 100644 index 4a78d3f..0000000 --- a/acceptance/tests/simple/http/fetchExisting.txtar +++ /dev/null @@ -1,21 +0,0 @@ --- req.http -- -GET /gophers/01D8EMQ185CA8PRGE20DKZTGSR HTTP/1.1 -Content-Type: application/json - --- res.http -- -HTTP/1.1 200 OK -Content-Length: 214 -Content-Type: application/json - - -{ - "data": { - "type": "gophers", - "id": "01D8EMQ185CA8PRGE20DKZTGSR", - "attributes": { - "name": "Zebediah", - "color": "Purple", - "age": 54 - } - } -} diff --git a/acceptance/tests/simple/http/fetchNotFound.txtar b/acceptance/tests/simple/http/fetchNotFound.txtar deleted file mode 100644 index 702a1e0..0000000 --- a/acceptance/tests/simple/http/fetchNotFound.txtar +++ /dev/null @@ -1,9 +0,0 @@ --- req.http -- -GET /gophers/01D8EMQ185CA8PRGE20DKZT404 HTTP/1.1 -Content-Type: application/json - --- res.http -- -HTTP/1.1 404 Not Found -Content-Length: 0 - - diff --git a/acceptance/tests/startup_invalid_proxy_mode/scenario.json b/acceptance/tests/startup_invalid_proxy_mode.txtar similarity index 55% rename from acceptance/tests/startup_invalid_proxy_mode/scenario.json rename to acceptance/tests/startup_invalid_proxy_mode.txtar index b9cffcb..d65fa36 100644 --- a/acceptance/tests/startup_invalid_proxy_mode/scenario.json +++ b/acceptance/tests/startup_invalid_proxy_mode.txtar @@ -1,3 +1,5 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- { "args": [ "--proxy-mode", diff --git a/acceptance/tests/startup_invalid_proxy_mode/config.txtar b/acceptance/tests/startup_invalid_proxy_mode/config.txtar deleted file mode 100644 index b3cf461..0000000 --- a/acceptance/tests/startup_invalid_proxy_mode/config.txtar +++ /dev/null @@ -1 +0,0 @@ -No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_malformed_config/scenario.json b/acceptance/tests/startup_malformed_config.txtar similarity index 74% rename from acceptance/tests/startup_malformed_config/scenario.json rename to acceptance/tests/startup_malformed_config.txtar index 06bbb90..a75a833 100644 --- a/acceptance/tests/startup_malformed_config/scenario.json +++ b/acceptance/tests/startup_malformed_config.txtar @@ -1,3 +1,6 @@ +-- bad.yml -- +host: [ +-- scenario.json -- { "args": [ "--config", diff --git a/acceptance/tests/startup_malformed_config/config.txtar b/acceptance/tests/startup_malformed_config/config.txtar deleted file mode 100644 index 0f2c034..0000000 --- a/acceptance/tests/startup_malformed_config/config.txtar +++ /dev/null @@ -1,2 +0,0 @@ --- bad.yml -- -host: [ diff --git a/acceptance/tests/startup_missing_config/scenario.json b/acceptance/tests/startup_missing_config.txtar similarity index 56% rename from acceptance/tests/startup_missing_config/scenario.json rename to acceptance/tests/startup_missing_config.txtar index 3ce2f6a..0d9ceef 100644 --- a/acceptance/tests/startup_missing_config/scenario.json +++ b/acceptance/tests/startup_missing_config.txtar @@ -1,3 +1,5 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- { "args": [ "--config", diff --git a/acceptance/tests/startup_missing_config/config.txtar b/acceptance/tests/startup_missing_config/config.txtar deleted file mode 100644 index b3cf461..0000000 --- a/acceptance/tests/startup_missing_config/config.txtar +++ /dev/null @@ -1 +0,0 @@ -No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_missing_imposters/scenario.json b/acceptance/tests/startup_missing_imposters.txtar similarity index 52% rename from acceptance/tests/startup_missing_imposters/scenario.json rename to acceptance/tests/startup_missing_imposters.txtar index fd8b550..4737192 100644 --- a/acceptance/tests/startup_missing_imposters/scenario.json +++ b/acceptance/tests/startup_missing_imposters.txtar @@ -1,3 +1,5 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- { "args": [ "--imposters", diff --git a/acceptance/tests/startup_missing_imposters/config.txtar b/acceptance/tests/startup_missing_imposters/config.txtar deleted file mode 100644 index b3cf461..0000000 --- a/acceptance/tests/startup_missing_imposters/config.txtar +++ /dev/null @@ -1 +0,0 @@ -No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_missing_proxy_url/scenario.json b/acceptance/tests/startup_missing_proxy_url.txtar similarity index 54% rename from acceptance/tests/startup_missing_proxy_url/scenario.json rename to acceptance/tests/startup_missing_proxy_url.txtar index 01aa8c1..ba4c7ce 100644 --- a/acceptance/tests/startup_missing_proxy_url/scenario.json +++ b/acceptance/tests/startup_missing_proxy_url.txtar @@ -1,3 +1,5 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- { "args": [ "--proxy-mode", diff --git a/acceptance/tests/startup_missing_proxy_url/config.txtar b/acceptance/tests/startup_missing_proxy_url/config.txtar deleted file mode 100644 index b3cf461..0000000 --- a/acceptance/tests/startup_missing_proxy_url/config.txtar +++ /dev/null @@ -1 +0,0 @@ -No fixture files are needed: this scenario fails during startup validation. diff --git a/acceptance/tests/startup_occupied_port/config.txtar b/acceptance/tests/startup_occupied_port.txtar similarity index 50% rename from acceptance/tests/startup_occupied_port/config.txtar rename to acceptance/tests/startup_occupied_port.txtar index 4572da9..fc27772 100644 --- a/acceptance/tests/startup_occupied_port/config.txtar +++ b/acceptance/tests/startup_occupied_port.txtar @@ -2,3 +2,16 @@ host: [ -- imposters/routes.imp.json -- [{"request": {"method": "GET", "endpoint": "/mock"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mock\n"}}] +-- scenario.json -- +{ + "args": [ + "--host", + "localhost", + "--port", + "${PORT}", + "--imposters", + "imposters" + ], + "error": "address already in use", + "occupyPort": true +} diff --git a/acceptance/tests/startup_occupied_port/scenario.json b/acceptance/tests/startup_occupied_port/scenario.json deleted file mode 100644 index f8ec8a3..0000000 --- a/acceptance/tests/startup_occupied_port/scenario.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "args": [ - "--host", - "localhost", - "--port", - "${PORT}", - "--imposters", - "imposters" - ], - "error": "address already in use", - "occupyPort": true -} diff --git a/acceptance/tests/watcher_config.txtar b/acceptance/tests/watcher_config.txtar new file mode 100644 index 0000000..bd83a6d --- /dev/null +++ b/acceptance/tests/watcher_config.txtar @@ -0,0 +1,111 @@ +-- imposters/routes.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/watched" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "original\n" + } + } +] +-- config.yml -- +host: localhost +port: ${PORT} +imposters_path: imposters +watcher: true + +-- scenario.json -- +{ + "args": [ + "--config", + "config.yml" + ] +} +-- http/01_initial/req.http -- +GET /watched HTTP/1.1 + + +-- http/01_initial/res.http -- +HTTP/1.1 200 OK +Content-Length: 9 +Content-Type: text/plain + + +original +-- http/02_update/req.http -- +GET /watched HTTP/1.1 + + +-- http/02_update/res.http -- +HTTP/1.1 200 OK +Content-Length: 14 +Content-Type: text/plain + + +updated route +-- http/02_update/write/imposters/routes.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/watched" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "updated route\n" + } + } +] +-- http/02_update/eventually -- +true +-- http/03_create/req.http -- +GET /created HTTP/1.1 + + +-- http/03_create/res.http -- +HTTP/1.1 200 OK +Content-Length: 9 +Content-Type: text/plain + + +new file +-- http/03_create/write/imposters/created.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/created" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "new file\n" + } + } +] +-- http/03_create/eventually -- +true +-- http/04_updated_still_served/req.http -- +GET /watched HTTP/1.1 + + +-- http/04_updated_still_served/res.http -- +HTTP/1.1 200 OK +Content-Length: 14 +Content-Type: text/plain + + +updated route +-- http/04_updated_still_served/eventually -- +true diff --git a/acceptance/tests/watcher_flag.txtar b/acceptance/tests/watcher_flag.txtar new file mode 100644 index 0000000..c2cc804 --- /dev/null +++ b/acceptance/tests/watcher_flag.txtar @@ -0,0 +1,108 @@ +-- imposters/routes.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/watched" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "original\n" + } + } +] +-- scenario.json -- +{ + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--watcher" + ] +} +-- http/01_initial/req.http -- +GET /watched HTTP/1.1 + + +-- http/01_initial/res.http -- +HTTP/1.1 200 OK +Content-Length: 9 +Content-Type: text/plain + + +original +-- http/02_update/req.http -- +GET /watched HTTP/1.1 + + +-- http/02_update/res.http -- +HTTP/1.1 200 OK +Content-Length: 14 +Content-Type: text/plain + + +updated route +-- http/02_update/write/imposters/routes.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/watched" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "updated route\n" + } + } +] +-- http/02_update/eventually -- +true +-- http/03_create/req.http -- +GET /created HTTP/1.1 + + +-- http/03_create/res.http -- +HTTP/1.1 200 OK +Content-Length: 9 +Content-Type: text/plain + + +new file +-- http/03_create/write/imposters/created.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/created" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "new file\n" + } + } +] +-- http/03_create/eventually -- +true +-- http/04_updated_still_served/req.http -- +GET /watched HTTP/1.1 + + +-- http/04_updated_still_served/res.http -- +HTTP/1.1 200 OK +Content-Length: 14 +Content-Type: text/plain + + +updated route +-- http/04_updated_still_served/eventually -- +true diff --git a/acceptance/tests/yaml_nested.txtar b/acceptance/tests/yaml_nested.txtar new file mode 100644 index 0000000..95519b6 --- /dev/null +++ b/acceptance/tests/yaml_nested.txtar @@ -0,0 +1,166 @@ +-- imposters/yml/nested/routes.imp.yml -- +- request: + method: POST + endpoint: /yml/{id:[0-9]+} + schemaFile: schemas/request.json + headers: + X-Key: ^token$ + params: + color: red + response: + status: 201 + headers: + Content-Type: text/plain + X-Source: yml + bodyFile: responses/body.txt +- request: + method: POST + endpoint: /yml/{id} + response: + status: 400 + headers: + Content-Type: text/plain + body: "fallback\n" +-- imposters/yml/nested/schemas/request.json -- +{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} +-- imposters/yml/nested/responses/body.txt -- +yml file body +-- imposters/yaml/nested/routes.imp.yaml -- +- request: + method: POST + endpoint: /yaml/{id:[0-9]+} + schemaFile: schemas/request.json + headers: + X-Key: ^token$ + params: + color: red + response: + status: 201 + headers: + Content-Type: text/plain + X-Source: yaml + bodyFile: responses/body.txt +- request: + method: POST + endpoint: /yaml/{id} + response: + status: 400 + headers: + Content-Type: text/plain + body: "fallback\n" +-- imposters/yaml/nested/schemas/request.json -- +{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} +-- imposters/yaml/nested/responses/body.txt -- +yaml file body +-- http/yaml_invalid_schema/req.http -- +POST /yaml/42?color=red HTTP/1.1 +X-Key: token +Content-Length: 12 + +{"name":42} + +-- http/yaml_invalid_schema/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/yaml_missing_header/req.http -- +POST /yaml/42?color=red HTTP/1.1 +Content-Length: 18 + +{"name":"gopher"} + +-- http/yaml_missing_header/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/yaml_valid/req.http -- +POST /yaml/42?color=red HTTP/1.1 +X-Key: token +Content-Length: 18 + +{"name":"gopher"} + +-- http/yaml_valid/res.http -- +HTTP/1.1 201 Created +Content-Length: 15 +Content-Type: text/plain +X-Source: yaml + + +yaml file body +-- http/yaml_wrong_query/req.http -- +POST /yaml/42?color=blue HTTP/1.1 +X-Key: token +Content-Length: 18 + +{"name":"gopher"} + +-- http/yaml_wrong_query/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/yml_invalid_schema/req.http -- +POST /yml/42?color=red HTTP/1.1 +X-Key: token +Content-Length: 12 + +{"name":42} + +-- http/yml_invalid_schema/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/yml_missing_header/req.http -- +POST /yml/42?color=red HTTP/1.1 +Content-Length: 18 + +{"name":"gopher"} + +-- http/yml_missing_header/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback +-- http/yml_valid/req.http -- +POST /yml/42?color=red HTTP/1.1 +X-Key: token +Content-Length: 18 + +{"name":"gopher"} + +-- http/yml_valid/res.http -- +HTTP/1.1 201 Created +Content-Length: 14 +Content-Type: text/plain +X-Source: yml + + +yml file body +-- http/yml_wrong_query/req.http -- +POST /yml/42?color=blue HTTP/1.1 +X-Key: token +Content-Length: 18 + +{"name":"gopher"} + +-- http/yml_wrong_query/res.http -- +HTTP/1.1 400 Bad Request +Content-Length: 9 +Content-Type: text/plain + + +fallback diff --git a/acceptance/utils/network/network.go b/acceptance/utils/network/network.go deleted file mode 100644 index 2b5f77c..0000000 --- a/acceptance/utils/network/network.go +++ /dev/null @@ -1,19 +0,0 @@ -package network - -import "net" - -func AnyAvailablePort() (int, error) { - // Create a new TCP listener on port 0 (which means "any available port") - listener, err := net.Listen("tcp", ":0") - if err != nil { - return 0, err - } - - // Extract the port number from the listener address - port := listener.Addr().(*net.TCPAddr).Port - - // Close the listener to free up the port - err = listener.Close() - - return port, err -} From 67918a78e96304e00ddcabf1357ff6b61a426c18 Mon Sep 17 00:00:00 2001 From: aperezg <4472006+aperezg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:25:58 +0100 Subject: [PATCH 5/7] Bug fix on yaml with schema files --- internal/server/http/imposter.go | 2 +- internal/server/http/imposter_test.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/server/http/imposter.go b/internal/server/http/imposter.go index 5ca451b..ef69438 100644 --- a/internal/server/http/imposter.go +++ b/internal/server/http/imposter.go @@ -61,7 +61,7 @@ func (i *Imposter) CalculateFilePath(filePath string) string { type Request struct { Method string `json:"method"` Endpoint string `json:"endpoint"` - SchemaFile *string `json:"schemaFile"` + SchemaFile *string `json:"schemaFile" yaml:"schemaFile"` Params *map[string]string `json:"params"` Headers *map[string]string `json:"headers"` } diff --git a/internal/server/http/imposter_test.go b/internal/server/http/imposter_test.go index 3077a92..4c2963e 100644 --- a/internal/server/http/imposter_test.go +++ b/internal/server/http/imposter_test.go @@ -125,8 +125,9 @@ func TestImposterFS_FindImposters(t *testing.T) { BasePath: "test/testdata/imposters", Path: "test_request.imp.yml", Request: Request{ - Method: "POST", - Endpoint: "/yamlGophers", + Method: "POST", + Endpoint: "/yamlGophers", + SchemaFile: &schemaFile, Headers: &map[string]string{ "Content-Type": "application/json", }, From 651f2c4002056503675ef6cfd9a0eaa317fae57c Mon Sep 17 00:00:00 2001 From: aperezg <4472006+aperezg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:37:28 +0100 Subject: [PATCH 6/7] Add acceptance coverage for HTTPS, response delays, and graceful shutdown --- LICENSE | 2 +- acceptance/README.md | 19 +++- acceptance/acceptance_test.go | 127 ++++++++++++++++++++++++- acceptance/tests/https_config.txtar | 50 ++++++++++ acceptance/tests/https_flag.txtar | 48 ++++++++++ acceptance/tests/https_proxy.txtar | 40 ++++++++ acceptance/tests/response_delays.txtar | 98 +++++++++++++++++++ internal/server/http/server_test.go | 17 ++-- 8 files changed, 389 insertions(+), 12 deletions(-) create mode 100644 acceptance/tests/https_config.txtar create mode 100644 acceptance/tests/https_flag.txtar create mode 100644 acceptance/tests/https_proxy.txtar create mode 100644 acceptance/tests/response_delays.txtar diff --git a/LICENSE b/LICENSE index b95d5b3..61e2608 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 friendsofgo +Copyright (c) 2026 friendsofgo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/acceptance/README.md b/acceptance/README.md index 490a23d..c55f3f8 100644 --- a/acceptance/README.md +++ b/acceptance/README.md @@ -53,6 +53,7 @@ Optional `scenario.json` settings: | `occupyPort` | Hold the allocated port open to exercise bind failure. | | `upstreamCalls` | Start a local upstream and assert this exact request count, including zero. | | `upstreamDown` | Close that upstream before startup to exercise a connection failure. | +| `https` | Send fixture requests over HTTPS. Startup arguments or configuration must also enable secure mode. | The upstream returns status 202, `X-Upstream: yes`, and a body echoing the method, request URI, `X-Test` header, and body. TCP readiness does not call the upstream @@ -64,6 +65,22 @@ containing `true` retries the response check for up to five seconds. Writes happ once. Use retries only for stateless GET requests without bodies; sequence tests must check every response without retries. See `watcher_flag.txtar` for an example. +Delay steps may include `http//timing.json`, for example +`{"min":"180ms","max":"2s"}`. The harness measures the complete response, with a +lower bound to detect missing delays and a generous upper allowance for CI load. +Fixed and ranged delays are covered in both JSON and YAML. Timing checks cannot +use retries. + +HTTPS clients accept Killgrave's bundled dummy certificate only within the test +transport. HTTPS proxy fixtures still connect to Killgrave before reaching the +local upstream; the unit suite separately checks a trusted local TLS upstream. + +`TestGracefulShutdown` is a Go table test for SIGINT and SIGTERM. It uses channels +to hold an upstream request open, checks that new connections are refused during +shutdown, then releases the response and verifies clean exit. This coordination +stays in Go rather than adding concurrency commands to the archive format. + HTTP requests time out after three seconds; readiness and startup-error commands have five-second limits. Cleanup allows three seconds for an interrupt before -killing and reaping the process. Failed scenarios include server logs. +killing and reaping the process, and verifies that the listening port can be bound +again. Failed scenarios include server logs. diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 3e9d9e0..67413d7 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -6,6 +6,7 @@ import ( "bufio" "bytes" "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -20,7 +21,9 @@ import ( "regexp" "strconv" "strings" + "sync" "sync/atomic" + "syscall" "testing" "time" @@ -107,6 +110,13 @@ func Test(t *testing.T) { client := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + if opts.HTTPS { + // Killgrave ships a dummy certificate. Restrict this test transport + // to HTTP/1.1 so raw response fixtures are independent of ALPN. + transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + client.Transport = transport + t.Cleanup(transport.CloseIdleConnections) + } // 3. Collect the request and response pairs // and iterate over them to perform the tests. @@ -116,6 +126,9 @@ func Test(t *testing.T) { t.Run(rr.name, func(t *testing.T) { // Override the address rr.overrideAddress(address) + if opts.HTTPS { + rr.req.URL.Scheme = "https" + } rr.run(t, client, path) }) @@ -178,6 +191,8 @@ type reqRes struct { res []byte writes []txtar.File eventually bool + minTime time.Duration + maxTime time.Duration } // overrideAddress changes the request's URL to use the provided address. @@ -220,11 +235,19 @@ func (rr reqRes) run(t *testing.T, client *http.Client, dir string) { }, 5*time.Second, 50*time.Millisecond) return } + start := time.Now() res, err := client.Do(rr.req) require.NoError(t, err) actual, err := formatResponse(res) require.NoError(t, err) + elapsed := time.Since(start) assert.Equal(t, string(rr.res), actual) + if rr.minTime > 0 { + assert.GreaterOrEqual(t, elapsed, rr.minTime, "response returned before the configured delay") + } + if rr.maxTime > 0 { + assert.LessOrEqual(t, elapsed, rr.maxTime, "response exceeded the timing allowance") + } } // formatResponse consumes and closes the body on every attempt, including retries. @@ -289,7 +312,22 @@ func collectRequestResponses(t *testing.T, files []txtar.File) (rrs []reqRes) { require.NoError(t, err) rr.eventually = eventually } + if file.Name == "timing.json" { + var timing struct { + Min string `json:"min"` + Max string `json:"max"` + } + require.NoError(t, json.Unmarshal(file.Data, &timing)) + var err error + rr.minTime, err = time.ParseDuration(timing.Min) + require.NoError(t, err) + rr.maxTime, err = time.ParseDuration(timing.Max) + require.NoError(t, err) + require.GreaterOrEqual(t, rr.minTime, time.Duration(0)) + require.GreaterOrEqual(t, rr.maxTime, rr.minTime) + } } + require.False(t, rr.eventually && rr.maxTime > 0, "timing checks cannot use retries") rrs = append(rrs, rr) } @@ -352,6 +390,7 @@ type scenario struct { OccupyPort bool `json:"occupyPort"` UpstreamCalls *int `json:"upstreamCalls"` UpstreamDown bool `json:"upstreamDown"` + HTTPS bool `json:"https"` } func readScenario(t *testing.T, files []txtar.File) scenario { @@ -390,9 +429,14 @@ func assertStartupError(t *testing.T, dir string, args []string, message string) assert.Contains(t, string(output), message) } +type runningApplication struct { + process *os.Process + done <-chan struct{} +} + // runApplication captures output in a file so concurrent writes and diagnostics // cannot race. TCP readiness does not consume responses or hit a proxy upstream. -func runApplication(t *testing.T, dir, address string, args []string) { +func runApplication(t *testing.T, dir, address string, args []string) runningApplication { t.Helper() cmd := applicationCommand(t, dir, args) outputPath := filepath.Join(dir, "process.log") @@ -427,6 +471,10 @@ func runApplication(t *testing.T, dir, address string, args []string) { } } assert.NoError(t, waitErr, "Killgrave process exit") + listener, err := net.Listen("tcp", address) + if assert.NoError(t, err, "Killgrave did not release its listening port") { + listener.Close() + } }) deadline := time.NewTimer(5 * time.Second) defer deadline.Stop() @@ -442,12 +490,87 @@ func runApplication(t *testing.T, dir, address string, args []string) { conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond) if err == nil { conn.Close() - return + return runningApplication{process: cmd.Process, done: done} } } } } +// This test uses a blocked local upstream to synchronize shutdown with an active +// request. Channels establish that the request is in flight without timing sleeps. +func TestGracefulShutdown(t *testing.T) { + for _, signal := range []os.Signal{os.Interrupt, syscall.SIGTERM} { + t.Run(signal.String(), func(t *testing.T) { + t.Parallel() + started, release := make(chan struct{}), make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + select { + case <-release: + io.WriteString(w, "completed\n") + case <-r.Context().Done(): + } + })) + t.Cleanup(upstream.Close) + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "imposters"), 0o755)) + port := strconv.Itoa(availablePort(t)) + address := net.JoinHostPort(addr, port) + app := runApplication(t, dir, address, []string{"--port", port, "--imposters", "imposters", "--proxy-mode", "all", "--proxy-url", upstream.URL}) + var releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + client := &http.Client{Timeout: 3 * time.Second} + t.Cleanup(client.CloseIdleConnections) + type result struct { + response *http.Response + err error + } + results := make(chan result, 1) + go func() { + res, err := client.Get("http://" + address + "/in-flight") + results <- result{res, err} + }() + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("request never reached the upstream") + } + require.NoError(t, app.process.Signal(signal)) + require.Eventually(t, func() bool { + conn, err := net.DialTimeout("tcp", address, 100*time.Millisecond) + if err != nil { + return true + } + conn.Close() + return false + }, time.Second, 10*time.Millisecond, "shutdown did not stop accepting connections") + select { + case <-app.done: + t.Fatal("Killgrave exited before its in-flight request completed") + default: + } + unblock() + select { + case got := <-results: + require.NoError(t, got.err) + defer got.response.Body.Close() + body, err := io.ReadAll(got.response.Body) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, got.response.StatusCode) + assert.Equal(t, "completed\n", string(body)) + case <-time.After(3 * time.Second): + t.Fatal("in-flight request did not complete during shutdown") + } + select { + case <-app.done: + case <-time.After(3 * time.Second): + t.Fatal("Killgrave did not exit after its in-flight request completed") + } + }) + } +} + // extractKillgraveVersion runs `killgrave --version` and uses a regular expression // to extract the version from the output. In case there's any error (e.g. the binary is not // available), it returns the error. diff --git a/acceptance/tests/https_config.txtar b/acceptance/tests/https_config.txtar new file mode 100644 index 0000000..94dccee --- /dev/null +++ b/acceptance/tests/https_config.txtar @@ -0,0 +1,50 @@ +-- imposters/routes.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/secure" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "secure\n" + } + } +] +-- config.yml -- +host: localhost +port: ${PORT} +imposters_path: imposters +secure: true +-- scenario.json -- +{ + "https": true, + "args": [ + "--config", + "config.yml" + ] +} +-- http/matched/req.http -- +GET /secure HTTP/1.1 + +-- http/matched/res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +secure +-- http/unmatched/req.http -- +GET /missing HTTP/1.1 + +-- http/unmatched/res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found diff --git a/acceptance/tests/https_flag.txtar b/acceptance/tests/https_flag.txtar new file mode 100644 index 0000000..a99717e --- /dev/null +++ b/acceptance/tests/https_flag.txtar @@ -0,0 +1,48 @@ +-- imposters/routes.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/secure" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "secure\n" + } + } +] +-- scenario.json -- +{ + "https": true, + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--secure" + ] +} +-- http/matched/req.http -- +GET /secure HTTP/1.1 + +-- http/matched/res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +secure +-- http/unmatched/req.http -- +GET /missing HTTP/1.1 + +-- http/unmatched/res.http -- +HTTP/1.1 404 Not Found +Content-Length: 19 +Content-Type: text/plain; charset=utf-8 +X-Content-Type-Options: nosniff + + +404 page not found diff --git a/acceptance/tests/https_proxy.txtar b/acceptance/tests/https_proxy.txtar new file mode 100644 index 0000000..3fc7693 --- /dev/null +++ b/acceptance/tests/https_proxy.txtar @@ -0,0 +1,40 @@ +-- imposters/routes.imp.json -- +[{"request": {"method": "GET", "endpoint": "/secure"}, "response": {"status": 200, "headers": {"Content-Type": "text/plain"}, "body": "mocked\n"}}] +-- scenario.json -- +{ + "https": true, + "upstreamCalls": 1, + "args": [ + "--port", + "${PORT}", + "--imposters", + "imposters", + "--secure", + "--proxy-mode", + "missing", + "--proxy-url", + "${UPSTREAM}" + ] +} +-- http/local/req.http -- +GET /secure HTTP/1.1 + +-- http/local/res.http -- +HTTP/1.1 200 OK +Content-Length: 7 +Content-Type: text/plain + + +mocked +-- http/upstream/req.http -- +GET /upstream?color=red HTTP/1.1 + +-- http/upstream/res.http -- +HTTP/1.1 202 Accepted +Content-Length: 33 +Content-Type: text/plain +X-Upstream: yes + + +GET /upstream?color=red +X-Test: diff --git a/acceptance/tests/response_delays.txtar b/acceptance/tests/response_delays.txtar new file mode 100644 index 0000000..c4617d2 --- /dev/null +++ b/acceptance/tests/response_delays.txtar @@ -0,0 +1,98 @@ +-- imposters/routes.imp.json -- +[ + { + "request": { + "method": "GET", + "endpoint": "/json/fixed" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "json fixed\n", + "delay": "200ms" + } + }, + { + "request": { + "method": "GET", + "endpoint": "/json/range" + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "text/plain" + }, + "body": "json range\n", + "delay": "150ms:250ms" + } + } +] +-- imposters/routes.imp.yaml -- +- request: + method: GET + endpoint: /yaml/fixed + response: + status: 200 + headers: + Content-Type: text/plain + body: "yaml fixed\n" + delay: 200ms +- request: + method: GET + endpoint: /yaml/range + response: + status: 200 + headers: + Content-Type: text/plain + body: "yaml range\n" + delay: 150ms:250ms +-- http/json_fixed/req.http -- +GET /json/fixed HTTP/1.1 + +-- http/json_fixed/res.http -- +HTTP/1.1 200 OK +Content-Length: 11 +Content-Type: text/plain + + +json fixed +-- http/json_fixed/timing.json -- +{"min": "180ms", "max": "2s"} +-- http/json_range/req.http -- +GET /json/range HTTP/1.1 + +-- http/json_range/res.http -- +HTTP/1.1 200 OK +Content-Length: 11 +Content-Type: text/plain + + +json range +-- http/json_range/timing.json -- +{"min": "130ms", "max": "2s"} +-- http/yaml_fixed/req.http -- +GET /yaml/fixed HTTP/1.1 + +-- http/yaml_fixed/res.http -- +HTTP/1.1 200 OK +Content-Length: 11 +Content-Type: text/plain + + +yaml fixed +-- http/yaml_fixed/timing.json -- +{"min": "180ms", "max": "2s"} +-- http/yaml_range/req.http -- +GET /yaml/range HTTP/1.1 + +-- http/yaml_range/res.http -- +HTTP/1.1 200 OK +Content-Length: 11 +Content-Type: text/plain + + +yaml range +-- http/yaml_range/timing.json -- +{"min": "130ms", "max": "2s"} diff --git a/internal/server/http/server_test.go b/internal/server/http/server_test.go index 26ea559..bd48fb4 100644 --- a/internal/server/http/server_test.go +++ b/internal/server/http/server_test.go @@ -142,8 +142,11 @@ func TestBuildSecureMode(t *testing.T) { Certificates: []tls.Certificate{cert}, }} + upstreamTransport := proxyServer.Client().Transport proxyServer, err := NewProxy(proxyServer.URL, mode) require.NoError(t, err) + // Trust only the local test upstream through its own TLS client transport. + proxyServer.server.Transport = upstreamTransport imposterFs, err := NewImposterFS("test/testdata/imposters_secure") require.NoError(t, err) @@ -159,21 +162,18 @@ func TestBuildSecureMode(t *testing.T) { url string body string status int - server *httptest.Server }{ "ProxyNone_Hit": { mode: killgrave.ProxyNone, url: "https://localhost:4430/testHTTPSRequest", body: "Handled", status: http.StatusOK, - server: proxyServer, }, "ProxyAlways_Hit": { mode: killgrave.ProxyAll, - url: proxyServer.URL, + url: "https://localhost:4430/testHTTPSRequest", body: "Proxied", status: http.StatusOK, - server: proxyServer, }, } for name, tc := range testCases { @@ -185,12 +185,13 @@ func TestBuildSecureMode(t *testing.T) { assert.Nil(t, err) s.Run() - client := tc.server.Client() - client.Transport = &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, + client := &http.Client{ + Timeout: time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, } + defer client.CloseIdleConnections() assert.Eventually(t, func() bool { response, err := client.Get(tc.url) From 4b99d365ba5ad7ce41bd0ac70e69402b156940e4 Mon Sep 17 00:00:00 2001 From: "Adrian P." <4472006+aperezg@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:40:30 +0100 Subject: [PATCH 7/7] Update .github/workflows/ci-test.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> --- .github/workflows/ci-test.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index f68ea2a..85b56eb 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -1,7 +1,10 @@ name: Test & Build on: + pull_request: push: + branches: + - main permissions: contents: read