diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml new file mode 100644 index 0000000..85b56eb --- /dev/null +++ b/.github/workflows/ci-test.yaml @@ -0,0 +1,34 @@ +name: Test & Build + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go: [ '1.27' ] + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: ${{ matrix.go }} + - name: Run unit tests + run: go test -race ./... + - name: Run static checks + 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/main.yaml b/.github/workflows/main.yaml deleted file mode 100644 index 2a66f8e..0000000 --- a/.github/workflows/main.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: Test & Build - -on: - pull_request: - push: - paths: - - 'cmd/**' - - 'internal/**' - - 'acceptance/**' - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - go: [ '1.21' ] - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v4 - with: - go-version: ${{ matrix.go }} - - name: Run unit tests - run: go test -v -vet=off -race ./... - - name: Run acceptance tests - run: make acceptance \ No newline at end of file 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 new file mode 100644 index 0000000..76360e9 --- /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.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. +- `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. 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 + +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/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/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 new file mode 100644 index 0000000..c55f3f8 --- /dev/null +++ b/acceptance/README.md @@ -0,0 +1,86 @@ +# Acceptance tests + +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 +``` + +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. + +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 | +| --- | --- | +| `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, 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 +or advance response sequences. + +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. + +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, 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 d2a1b6b..67413d7 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -5,12 +5,15 @@ package acceptance import ( "bufio" "bytes" + "context" + "crypto/tls" + "encoding/json" "errors" "fmt" "io" - "io/fs" - "log" + "net" "net/http" + "net/http/httptest" "net/url" "os" "os/exec" @@ -18,14 +21,15 @@ import ( "regexp" "strconv" "strings" + "sync" + "sync/atomic" + "syscall" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/tools/txtar" - - "github.com/friendsofgo/killgrave/acceptance/utils/network" ) const ( @@ -43,35 +47,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) - 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. - // 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. - // 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 { @@ -80,61 +62,121 @@ func Test(t *testing.T) { t.Parallel() // 1. Create a temporary directory with the configuration files. - path := createTmpCfgDir(t, tc) + opts := readScenario(t, tc.files) + port := availablePort(t) + 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 + }} + 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. - 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) + if opts.HTTPS { + rr.req.URL.Scheme = "https" + } - // Send the request - res, err := http.DefaultClient.Do(rr.req) - require.NoError(t, err) - - // Assert the res - rr.assertResponse(t, res) + rr.run(t, client, path) }) } + if opts.UpstreamCalls != nil { + assert.Equal(t, *opts.UpstreamCalls, int(calls.Load()), "upstream request count") + } }) } } -// 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 } @@ -144,9 +186,13 @@ 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 + minTime time.Duration + maxTime time.Duration } // overrideAddress changes the request's URL to use the provided address. @@ -157,17 +203,60 @@ 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 + } + 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") + } +} - // 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) @@ -178,38 +267,68 @@ 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 + } + 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) } return @@ -239,95 +358,229 @@ 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(). -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) - cfgFilePath := filepath.Join(tc.path, "config.txtar") - contents, err := os.ReadFile(cfgFilePath) - require.NoError(t, err) - archive := txtar.Parse(contents) +// 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 { + t.Helper() + tmpCfgDir := t.TempDir() - // 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) 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) } - // 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 } -// 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"` + HTTPS bool `json:"https"` +} + +func readScenario(t *testing.T, files []txtar.File) scenario { + t.Helper() + data := find(files, "scenario.json") + if data == nil { + return scenario{} + } + var opts scenario + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + require.NoError(t, decoder.Decode(&opts)) + return opts +} + +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 +} - // 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 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) +} - err = cmd.Wait() - require.NoError(t, err) +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) runningApplication { + 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) + } + }) + 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") + 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() + 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 runningApplication{process: cmd.Process, done: done} + } + } + } +} - // 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") - return err == nil && res != nil && res.StatusCode == http.StatusNotFound - }, maxWaitTime, checkEvery) - - return address +// 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 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) @@ -340,7 +593,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.txtar b/acceptance/tests/config_file.txtar new file mode 100644 index 0000000..6a94284 --- /dev/null +++ b/acceptance/tests/config_file.txtar @@ -0,0 +1,26 @@ +-- 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 +-- 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_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/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/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/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/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_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_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_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/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/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.txtar b/acceptance/tests/startup_invalid_proxy_mode.txtar new file mode 100644 index 0000000..d65fa36 --- /dev/null +++ b/acceptance/tests/startup_invalid_proxy_mode.txtar @@ -0,0 +1,10 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- +{ + "args": [ + "--proxy-mode", + "invalid" + ], + "error": "unknown proxy mode: invalid", + "occupyPort": false +} diff --git a/acceptance/tests/startup_malformed_config.txtar b/acceptance/tests/startup_malformed_config.txtar new file mode 100644 index 0000000..a75a833 --- /dev/null +++ b/acceptance/tests/startup_malformed_config.txtar @@ -0,0 +1,11 @@ +-- bad.yml -- +host: [ +-- scenario.json -- +{ + "args": [ + "--config", + "bad.yml" + ], + "error": "error while unmarshalling configFile", + "occupyPort": false +} diff --git a/acceptance/tests/startup_missing_config.txtar b/acceptance/tests/startup_missing_config.txtar new file mode 100644 index 0000000..0d9ceef --- /dev/null +++ b/acceptance/tests/startup_missing_config.txtar @@ -0,0 +1,10 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- +{ + "args": [ + "--config", + "missing.yml" + ], + "error": "error trying to read config file", + "occupyPort": false +} diff --git a/acceptance/tests/startup_missing_imposters.txtar b/acceptance/tests/startup_missing_imposters.txtar new file mode 100644 index 0000000..4737192 --- /dev/null +++ b/acceptance/tests/startup_missing_imposters.txtar @@ -0,0 +1,10 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- +{ + "args": [ + "--imposters", + "missing" + ], + "error": "does not exist", + "occupyPort": false +} diff --git a/acceptance/tests/startup_missing_proxy_url.txtar b/acceptance/tests/startup_missing_proxy_url.txtar new file mode 100644 index 0000000..ba4c7ce --- /dev/null +++ b/acceptance/tests/startup_missing_proxy_url.txtar @@ -0,0 +1,10 @@ +No fixture files are needed: this scenario fails during startup validation. +-- scenario.json -- +{ + "args": [ + "--proxy-mode", + "missing" + ], + "error": "proxy-url is mandatory", + "occupyPort": false +} diff --git a/acceptance/tests/startup_occupied_port.txtar b/acceptance/tests/startup_occupied_port.txtar new file mode 100644 index 0000000..fc27772 --- /dev/null +++ b/acceptance/tests/startup_occupied_port.txtar @@ -0,0 +1,17 @@ +-- bad.yml -- +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/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 -} 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= 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", }, 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)