diff --git a/Justfile b/Justfile index 29ab451..73bd40e 100644 --- a/Justfile +++ b/Justfile @@ -33,15 +33,38 @@ test: test-race: go test ./... -race -[doc("Run tests with coverage")] +[doc("Run tests with coverage (unit + end-to-end, merged)")] test-cover: - go test ./... -cover + #!/usr/bin/env bash + # The end-to-end suite drives a separate binary, so its coverage arrives as + # counter files rather than in the unit-test profile. Collect both into + # covdata directories and merge, otherwise everything main.go does at + # runtime reads as uncovered. + set -euo pipefail + unit=$(mktemp -d); e2e=$(mktemp -d) + trap 'rm -rf "$unit" "$e2e"' EXIT + E2E_COVERDIR="$e2e" go test ./... -cover -args -test.gocoverdir="$unit" + go tool covdata percent -i="$unit,$e2e" [doc("Run tests with coverage and generate HTML report")] test-coverage: - go test ./... -coverprofile=coverage.out + #!/usr/bin/env bash + set -euo pipefail + unit=$(mktemp -d); e2e=$(mktemp -d) + trap 'rm -rf "$unit" "$e2e"' EXIT + E2E_COVERDIR="$e2e" go test ./... -cover -args -test.gocoverdir="$unit" + go tool covdata textfmt -i="$unit,$e2e" -o=coverage.out go tool cover -html=coverage.out -o coverage.html - @echo "Coverage report: coverage.html" + echo "Coverage report: coverage.html" + +[doc("Show per-function coverage, lowest first")] +test-cover-func: + #!/usr/bin/env bash + set -euo pipefail + unit=$(mktemp -d); e2e=$(mktemp -d) + trap 'rm -rf "$unit" "$e2e"' EXIT + E2E_COVERDIR="$e2e" go test ./... -cover -args -test.gocoverdir="$unit" >/dev/null + go tool covdata func -i="$unit,$e2e" | sort -k2 -n [doc("Clean build artifacts")] clean: diff --git a/README.md b/README.md index 61300fd..aaf4a97 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,16 @@ http-assert \ ### Environment Variables -You can also configure the tool using environment variables with the `HTTP_ASSERT_` prefix: +Six options can be set through the environment, using the `HTTP_ASSERT_` prefix with dashes replaced by underscores: + +| Variable | Equivalent flag | +|----------|-----------------| +| `HTTP_ASSERT_VERBOSE` | `--verbose` | +| `HTTP_ASSERT_SILENT` | `--silent` | +| `HTTP_ASSERT_LOG_LEVEL` | `--log-level` | +| `HTTP_ASSERT_INSECURE` | `--insecure` | +| `HTTP_ASSERT_MAX_TIME` | `--max-time` | +| `HTTP_ASSERT_MAPHOST` | `--maphost` | ```bash export HTTP_ASSERT_VERBOSE=true @@ -188,6 +197,22 @@ export HTTP_ASSERT_INSECURE=true http-assert --assert-ok https://api.example.com ``` +**The remaining options are command-line only.** `--request`, `--header`, `--data` and every `--assert-*` flag ignore the environment; setting `HTTP_ASSERT_REQUEST=POST` has no effect. + +**A command-line flag always wins over the environment**, which in turn wins over the built-in default. + +**`HTTP_ASSERT_MAPHOST` separates multiple mappings with whitespace, not commas:** + +```bash +# Two mappings +export HTTP_ASSERT_MAPHOST="api.example.com:443=backend1:8443 api.example.com:80=backend1:8080" + +# NOT a list -- parsed as one malformed mapping, exits 71 +export HTTP_ASSERT_MAPHOST="api.example.com:443=backend1:8443,api.example.com:80=backend1:8080" +``` + +Repeating `--maphost` on the command line accumulates as usual. + ### Exit Codes - `0`: All assertions passed diff --git a/e2e_assert_test.go b/e2e_assert_test.go new file mode 100644 index 0000000..2d67df0 --- /dev/null +++ b/e2e_assert_test.go @@ -0,0 +1,223 @@ +package main_test + +import "testing" + +// TestE2EAssertions exercises every assertion option in both directions: +// a request that satisfies it, and one that does not. Passing only the happy +// case would not prove the assertion is doing any work. +func TestE2EAssertions(t *testing.T) { + cases := []struct { + Name string + Args []string // assertion under test + Pass string // URL where it holds + Fail string // URL where it does not + Diag string // fragment the failure message must carry + }{ + {"assert-ok", []string{"--assert-ok"}, url("/ok"), url("/500"), "ok: expected OK, got 500"}, + {"assert-status", []string{"--assert-status", "201"}, url("/created"), url("/ok"), "status: expected 201, got 200"}, + {"assert-header", []string{"--assert-header", `Cache-Control: max-age=\d+`}, url("/ok"), url("/created"), "header[Cache-Control]"}, + {"assert-header-eq", []string{"--assert-header-eq", "X-Api-Version: v1"}, url("/ok"), url("/created"), "header[X-Api-Version]"}, + {"assert-header-present", []string{"--assert-header-eq", "X-Api-Version"}, url("/ok"), url("/created"), "expected to be present"}, + {"assert-header-missing", []string{"--assert-header-missing", "X-Api-Version"}, url("/created"), url("/ok"), "expected to be missing"}, + {"assert-body", []string{"--assert-body", `"users":\s*\[\]`}, url("/ok"), url("/created"), "body: expected to match"}, + {"assert-body-eq", []string{"--assert-body-eq", "created"}, url("/created"), url("/ok"), "body: expected"}, + {"assert-body-empty", []string{"--assert-body-empty"}, url("/empty"), url("/ok"), "expected to be empty"}, + {"assert-redirect", []string{"--assert-redirect", `https://.*\.com/.*`}, url("/redirect"), url("/ok"), "redirect: wrong HTTP status"}, + {"assert-redirect-eq", []string{"--assert-redirect-eq", "https://new-domain.com/path"}, url("/redirect"), url("/redirect-rel"), "redirect: wrong Location"}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + t.Run("holds", func(t *testing.T) { + assertExit(t, run(t, nil, append(append([]string{}, tc.Args...), tc.Pass)...), exitOK) + }) + + t.Run("does not hold", func(t *testing.T) { + r := run(t, nil, append(append([]string{}, tc.Args...), tc.Fail)...) + assertExit(t, r, exitRequestFail) + assertContains(t, r, tc.Diag) + }) + }) + } +} + +// TestE2EAssertionAggregation pins the behaviour that separates this tool from +// `curl && grep`: every failing assertion is reported, not just the first. +func TestE2EAssertionAggregation(t *testing.T) { + r := run(t, nil, + "--assert-ok", + "--assert-status", "200", + "--assert-header-eq", "X-Absent: 1", + "--assert-body-eq", "not-boom", + url("/500")) + + assertExit(t, r, exitRequestFail) + assertContains(t, r, "4 assertions failed:") + for _, want := range []string{"ok: expected OK", "status: expected 200", "header[X-Absent]", "body: expected"} { + assertContains(t, r, want) + } +} + +// TestE2EExitCodes pins the process contract. Callers branch on these, and +// nothing in the repo verified them before this suite (#24). +func TestE2EExitCodes(t *testing.T) { + cases := []struct { + Name string + Args []string + Env map[string]string + Want int + Diag string + }{ + {Name: "success", Args: []string{"--assert-ok", url("/ok")}, Want: exitOK}, + {Name: "assertion failed", Args: []string{"--assert-ok", url("/500")}, Want: exitRequestFail, Diag: "assertions failed"}, + {Name: "connection refused", Args: []string{"--assert-ok", "http://127.0.0.1:9/"}, Want: exitRequestFail, Diag: "failed to send request"}, + {Name: "dns failure", Args: []string{"--assert-ok", "http://nonexistent.invalid/"}, Want: exitRequestFail, Diag: "failed to send request"}, + {Name: "unsupported scheme", Args: []string{"--assert-ok", "ftp://example.com/x"}, Want: exitRequestFail, Diag: "unsupported protocol scheme"}, + {Name: "tls verification", Args: []string{"--assert-ok", tlsSrv.URL}, Want: exitRequestFail, Diag: "certificate"}, + {Name: "invalid log level", Args: []string{"--log-level", "trace", "--assert-ok", url("/ok")}, Want: exitBadFlagVal, Diag: "Invalid value for --log-level"}, + {Name: "invalid maphost", Args: []string{"--maphost", "garbage", "--assert-ok", url("/ok")}, Want: exitBadFlagVal, Diag: "Invalid value for --maphost"}, + {Name: "invalid method", Args: []string{"-X", "BAD METHOD", "--assert-ok", url("/ok")}, Want: exitBadRequest, Diag: "Cannot create request"}, + {Name: "malformed url", Args: []string{"--assert-ok", "ht!tp://[bad"}, Want: exitBadRequest, Diag: "Cannot create request"}, + {Name: "no url", Args: []string{"--assert-ok"}, Want: exitUsage, Diag: "accepts 1 arg(s)"}, + {Name: "too many urls", Args: []string{"--assert-ok", url("/ok"), url("/created")}, Want: exitUsage, Diag: "accepts 1 arg(s)"}, + {Name: "unknown flag", Args: []string{"--nope", url("/ok")}, Want: exitUsage, Diag: "unknown flag"}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + r := run(t, tc.Env, tc.Args...) + assertExit(t, r, tc.Want) + if tc.Diag != "" { + assertContains(t, r, tc.Diag) + } + }) + } +} + +// TestE2ERequestOptions covers the request-shaping options against the echo +// endpoint. The failing assertion is deliberate: it makes the CLI dump the +// response, which is the only way the echoed request becomes observable. +func TestE2ERequestOptions(t *testing.T) { + echo := url("/echo") + dump := []string{"--assert-body-eq", "never-matches"} + + t.Run("method", func(t *testing.T) { + r := run(t, nil, append(append([]string{"-X", "PUT"}, dump...), echo)...) + assertContains(t, r, `\"method\":\"PUT\"`) + }) + + t.Run("repeated headers accumulate", func(t *testing.T) { + r := run(t, nil, append(append([]string{"-H", "X-One: 1", "-H", "X-Two: 2"}, dump...), echo)...) + assertContains(t, r, "X-One") + assertContains(t, r, "X-Two") + }) + + t.Run("header names are canonicalised", func(t *testing.T) { + r := run(t, nil, append(append([]string{"-H", "x-lower-case: v"}, dump...), echo)...) + assertContains(t, r, "X-Lower-Case") + }) + + t.Run("body is sent", func(t *testing.T) { + r := run(t, nil, append(append([]string{"-X", "POST", "-d", "payload-here"}, dump...), echo)...) + assertContains(t, r, "payload-here") + }) + + t.Run("multi-value response headers match on any value", func(t *testing.T) { + assertExit(t, run(t, nil, "--assert-header-eq", "Set-Cookie: b=2", url("/multi")), exitOK) + }) +} + +// TestE2EHostMapping covers the option that distinguishes this tool from curl. +func TestE2EHostMapping(t *testing.T) { + target := "http://mapped.invalid/ok" + + t.Run("host and port", func(t *testing.T) { + assertExit(t, run(t, nil, "--maphost", "mapped.invalid:80="+hostPort(), "--assert-ok", target), exitOK) + }) + + t.Run("port wildcard", func(t *testing.T) { + assertExit(t, run(t, nil, "--maphost", "*:80="+hostPort(), "--assert-ok", target), exitOK) + }) + + t.Run("repeated mappings accumulate", func(t *testing.T) { + assertExit(t, run(t, nil, + "--maphost", "decoy.invalid:80=127.0.0.1:9", + "--maphost", "mapped.invalid:80="+hostPort(), + "--assert-ok", target), exitOK) + }) + + t.Run("unmapped hosts are untouched", func(t *testing.T) { + r := run(t, nil, "--maphost", "other.invalid:80="+hostPort(), "--assert-ok", target) + assertExit(t, r, exitRequestFail) + }) + + t.Run("mapping is logged at debug level", func(t *testing.T) { + r := run(t, nil, "-v", "--maphost", "mapped.invalid:80="+hostPort(), "--assert-ok", target) + assertContains(t, r, "mapped.invalid:80") + }) +} + +// TestE2ELogLevels covers every accepted --log-level value. info and debug are +// here for completeness: without them the level parser is only half exercised, +// and a typo in an unused branch would go unnoticed. +func TestE2ELogLevels(t *testing.T) { + okURL := url("/ok") + + t.Run("debug shows the host-mapping summary", func(t *testing.T) { + r := run(t, nil, "--log-level", "debug", + "--maphost", "mapped.invalid:80="+hostPort(), + "--assert-ok", "http://mapped.invalid/ok") + assertExit(t, r, exitOK) + assertContains(t, r, "HostMappings") + }) + + t.Run("info is the default and reports the result", func(t *testing.T) { + r := run(t, nil, "--log-level", "info", "--assert-ok", okURL) + assertExit(t, r, exitOK) + assertContains(t, r, "PASSED") + }) + + t.Run("info matches the unset default", func(t *testing.T) { + explicit := run(t, nil, "--log-level", "info", "--assert-ok", okURL) + implicit := run(t, nil, "--assert-ok", okURL) + if (explicit.Output() == "") != (implicit.Output() == "") { + t.Fatalf("explicit info differs from the default\n%q\n%q", explicit.Output(), implicit.Output()) + } + }) + + t.Run("warn and error suppress the result line", func(t *testing.T) { + for _, lvl := range []string{"warn", "error"} { + r := run(t, nil, "--log-level", lvl, "--assert-ok", okURL) + assertExit(t, r, exitOK) + assertNotContains(t, r, "PASSED") + } + }) +} + +// TestE2EHeaderPresenceAssertions covers the bare-name form of both header +// assertion flags, where the absence of a value means "assert present" rather +// than "assert equal to the empty string". +func TestE2EHeaderPresenceAssertions(t *testing.T) { + for _, flag := range []string{"--assert-header", "--assert-header-eq"} { + t.Run(flag, func(t *testing.T) { + t.Run("present", func(t *testing.T) { + assertExit(t, run(t, nil, flag, "X-Api-Version", url("/ok")), exitOK) + }) + + t.Run("absent", func(t *testing.T) { + r := run(t, nil, flag, "X-Api-Version", url("/created")) + assertExit(t, r, exitRequestFail) + assertContains(t, r, "expected to be present") + }) + }) + } +} + +// TestE2ELargePayloadCropped covers the crop path in the response dump: bodies +// over 256 bytes are truncated and the hidden byte count is reported. +func TestE2ELargePayloadCropped(t *testing.T) { + r := run(t, nil, "--assert-body-eq", "never-matches", url("/big")) + assertExit(t, r, exitRequestFail) + assertContains(t, r, "Payload is cropped") + assertContains(t, r, "4744 bytes are hidden") +} diff --git a/e2e_config_test.go b/e2e_config_test.go new file mode 100644 index 0000000..7961e34 --- /dev/null +++ b/e2e_config_test.go @@ -0,0 +1,247 @@ +package main_test + +import ( + "strings" + "testing" +) + +// TestE2EConfigContract is the executable specification of how http-assert +// resolves configuration. It exists so that removing viper (#54) can be +// verified rather than hoped: run it before the change, run it after, and any +// difference is a regression. +// +// Every option is exercised three ways -- unset, set on the command line, and +// set through the environment -- and the outcome is pinned. Precedence between +// the two sources is covered separately by TestE2EConfigPrecedence. +// +// EnvSupported records what the tool does *today*, not what it should do. Only +// 6 of the 19 options honour the environment; the other 13 silently ignore it +// (#54 proposes making this uniform). When that lands, flip those booleans -- +// the diff is the proof the change did what it claimed. + +type configCase struct { + // Flag is the long flag name, without leading dashes. + Flag string + // CLI sets the option on the command line. + CLI []string + // EnvKey/EnvVal set the same option through the environment. + EnvKey string + EnvVal string + // EnvSupported is whether EnvKey currently has any effect at all. + EnvSupported bool + // Issue, when non-zero, is the GitHub issue tracking the fact that + // EnvSupported is false when it arguably should be true. + Issue int + // Base is the rest of the invocation, appended after the option under test. + Base []string + // Applied reports whether the option visibly took effect. + Applied func(r result) bool +} + +// noAssertions is the error the CLI emits when no --assert-* flag was parsed. +// For every assertion option, "did it apply?" reduces to "is this absent?". +const noAssertions = "no assertions defined" + +func assertionApplied(r result) bool { return !strings.Contains(r.Output(), noAssertions) } + +func configCases(t *testing.T) []configCase { + t.Helper() + + okURL := url("/ok") + mapping := "mapped.invalid:80=" + hostPort() + + // An assertion option is "applied" when the CLI stops complaining that it + // has nothing to check. Every assertion flag shares this shape. + assertion := func(flag string, cli []string, env, target string) configCase { + return configCase{ + Flag: flag, CLI: cli, + EnvKey: env, EnvVal: cli[len(cli)-1], EnvSupported: false, Issue: 54, + Base: []string{target}, Applied: assertionApplied, + } + } + + return []configCase{ + // ---- options wired through viper: the environment works ---- + { + Flag: "verbose", CLI: []string{"-v"}, + EnvKey: "HTTP_ASSERT_VERBOSE", EnvVal: "true", EnvSupported: true, + Base: []string{"--maphost", mapping, "--assert-ok", "http://mapped.invalid/ok"}, + // The host-mapping summary is logged at debug level only. + Applied: func(r result) bool { return strings.Contains(r.Output(), "HostMappings") }, + }, + { + Flag: "silent", CLI: []string{"-s"}, + EnvKey: "HTTP_ASSERT_SILENT", EnvVal: "true", EnvSupported: true, + Base: []string{"--assert-ok", okURL}, + Applied: func(r result) bool { return r.Output() == "" }, + }, + { + Flag: "log-level", CLI: []string{"--log-level", "error"}, + EnvKey: "HTTP_ASSERT_LOG_LEVEL", EnvVal: "error", EnvSupported: true, + Base: []string{"--assert-ok", okURL}, + Applied: func(r result) bool { return r.Output() == "" }, + }, + { + Flag: "insecure", CLI: []string{"-k"}, + EnvKey: "HTTP_ASSERT_INSECURE", EnvVal: "true", EnvSupported: true, + Base: []string{"--assert-ok", tlsSrv.URL}, + // The certificate is self-signed, so success *is* the observation. + Applied: func(r result) bool { return r.ExitCode == exitOK }, + }, + { + Flag: "max-time", CLI: []string{"-m", "1"}, + EnvKey: "HTTP_ASSERT_MAX_TIME", EnvVal: "1", EnvSupported: true, + Base: []string{"--assert-ok", url("/slow")}, + Applied: func(r result) bool { return strings.Contains(r.Output(), "Client.Timeout") }, + }, + { + Flag: "maphost", CLI: []string{"--maphost", mapping}, + EnvKey: "HTTP_ASSERT_MAPHOST", EnvVal: mapping, EnvSupported: true, + Base: []string{"--assert-ok", "http://mapped.invalid/ok"}, + // Without the mapping the host does not resolve at all. + Applied: func(r result) bool { return r.ExitCode == exitOK }, + }, + + // ---- options read straight off cobra: the environment is ignored ---- + { + Flag: "request", CLI: []string{"-X", "POST"}, + EnvKey: "HTTP_ASSERT_REQUEST", EnvVal: "POST", EnvSupported: false, Issue: 54, + // A deliberately failing assertion makes the CLI dump the response, + // which is where the echoed method becomes observable. + Base: []string{"--assert-body-eq", "never-matches", url("/echo")}, + Applied: func(r result) bool { return strings.Contains(r.Output(), `\"method\":\"POST\"`) }, + }, + { + Flag: "header", CLI: []string{"-H", "X-Probe: 1"}, + EnvKey: "HTTP_ASSERT_HEADER", EnvVal: "X-Probe: 1", EnvSupported: false, Issue: 54, + Base: []string{"--assert-body-eq", "never-matches", url("/echo")}, + Applied: func(r result) bool { return strings.Contains(r.Output(), "X-Probe") }, + }, + { + Flag: "data", CLI: []string{"-d", "probe-payload"}, + EnvKey: "HTTP_ASSERT_DATA", EnvVal: "probe-payload", EnvSupported: false, Issue: 54, + Base: []string{"--assert-body-eq", "never-matches", url("/echo")}, + Applied: func(r result) bool { return strings.Contains(r.Output(), "probe-payload") }, + }, + + // ---- assertion options: all cobra-only ---- + assertion("assert-ok", []string{"--assert-ok"}, "HTTP_ASSERT_ASSERT_OK", okURL), + assertion("assert-status", []string{"--assert-status", "200"}, "HTTP_ASSERT_ASSERT_STATUS", okURL), + assertion("assert-header", []string{"--assert-header", `Cache-Control: max-age=\d+`}, "HTTP_ASSERT_ASSERT_HEADER", okURL), + assertion("assert-header-eq", []string{"--assert-header-eq", "X-Api-Version: v1"}, "HTTP_ASSERT_ASSERT_HEADER_EQ", okURL), + assertion("assert-header-missing", []string{"--assert-header-missing", "X-Absent"}, "HTTP_ASSERT_ASSERT_HEADER_MISSING", okURL), + assertion("assert-body", []string{"--assert-body", `"status"`}, "HTTP_ASSERT_ASSERT_BODY", okURL), + assertion("assert-body-eq", []string{"--assert-body-eq", `{"status":"success","users":[]}`}, "HTTP_ASSERT_ASSERT_BODY_EQ", okURL), + assertion("assert-body-empty", []string{"--assert-body-empty"}, "HTTP_ASSERT_ASSERT_BODY_EMPTY", url("/empty")), + assertion("assert-redirect", []string{"--assert-redirect", `https://.*\.com/.*`}, "HTTP_ASSERT_ASSERT_REDIRECT", url("/redirect")), + assertion("assert-redirect-eq", []string{"--assert-redirect-eq", "https://new-domain.com/path"}, "HTTP_ASSERT_ASSERT_REDIRECT_EQ", url("/redirect")), + } +} + +func TestE2EConfigContract(t *testing.T) { + cases := configCases(t) + + // Guards against an option being added to the CLI and quietly skipped here. + if got, want := len(cases), 19; got != want { + t.Fatalf("config matrix covers %d options, want %d -- add the new flag to configCases", got, want) + } + + for _, tc := range cases { + t.Run(tc.Flag, func(t *testing.T) { + t.Run("unset", func(t *testing.T) { + r := run(t, nil, tc.Base...) + if tc.Applied(r) { + t.Fatalf("%s took effect with neither flag nor env set\n%s", tc.Flag, r.Output()) + } + }) + + t.Run("cli", func(t *testing.T) { + r := run(t, nil, append(append([]string{}, tc.CLI...), tc.Base...)...) + if !tc.Applied(r) { + t.Fatalf("%s did not take effect via the command line\n%s", tc.Flag, r.Output()) + } + }) + + t.Run("env", func(t *testing.T) { + if !tc.EnvSupported { + characterizes(t, tc.Issue, + tc.EnvKey+" is ignored; only 6 of 19 options read the environment") + } + r := run(t, map[string]string{tc.EnvKey: tc.EnvVal}, tc.Base...) + if got := tc.Applied(r); got != tc.EnvSupported { + t.Fatalf("%s via %s: applied=%v, want %v\n%s", + tc.Flag, tc.EnvKey, got, tc.EnvSupported, r.Output()) + } + }) + }) + } +} + +// TestE2EConfigPrecedence pins the resolution order between the two sources. +// It is tested on max-time because the value is directly observable: a 1s +// budget cannot survive the 2s /slow endpoint, and a 20s budget always can. +func TestE2EConfigPrecedence(t *testing.T) { + slow := url("/slow") + + timedOut := func(r result) bool { return strings.Contains(r.Output(), "Client.Timeout") } + + t.Run("command line beats environment", func(t *testing.T) { + r := run(t, map[string]string{"HTTP_ASSERT_MAX_TIME": "1"}, "-m", "20", "--assert-ok", slow) + if timedOut(r) { + t.Fatalf("env won over the command line; the request timed out\n%s", r.Output()) + } + assertExit(t, r, exitOK) + }) + + // The mirror image. Without it, a build that simply ignored the environment + // would also pass the case above. + t.Run("command line beats environment, reversed", func(t *testing.T) { + r := run(t, map[string]string{"HTTP_ASSERT_MAX_TIME": "20"}, "-m", "1", "--assert-ok", slow) + if !timedOut(r) { + t.Fatalf("env won over the command line; the request completed\n%s", r.Output()) + } + assertExit(t, r, exitRequestFail) + }) + + t.Run("environment beats the built-in default", func(t *testing.T) { + r := run(t, map[string]string{"HTTP_ASSERT_MAX_TIME": "1"}, "--assert-ok", slow) + if !timedOut(r) { + t.Fatalf("env did not override the 20s default\n%s", r.Output()) + } + }) +} + +// TestE2EConfigEnvSliceSeparator pins how a repeatable option is expressed in a +// single environment variable. +// +// This is the least obvious part of the whole contract and the most likely +// casualty of #54: viper splits on WHITESPACE, not commas. A reimplementation +// reaching for strings.Split(v, ",") -- the intuitive choice -- would break +// every multi-mapping user, and nothing else in the suite would notice. +func TestE2EConfigEnvSliceSeparator(t *testing.T) { + // The first mapping points nowhere; only the second can satisfy the request. + // So a pass proves both entries were parsed, not just the leading one. + decoy := "decoy.invalid:80=127.0.0.1:9" + real := "mapped.invalid:80=" + hostPort() + target := "http://mapped.invalid/ok" + + t.Run("whitespace separates values", func(t *testing.T) { + r := run(t, map[string]string{"HTTP_ASSERT_MAPHOST": decoy + " " + real}, + "--assert-ok", target) + assertExit(t, r, exitOK) + }) + + t.Run("commas do not separate values", func(t *testing.T) { + r := run(t, map[string]string{"HTTP_ASSERT_MAPHOST": decoy + "," + real}, + "--assert-ok", target) + // The whole string is taken as one mapping, whose destination port is + // then unparseable. + assertExit(t, r, exitBadFlagVal) + assertContains(t, r, "Invalid value for --maphost flag") + }) + + t.Run("a single value needs no separator", func(t *testing.T) { + r := run(t, map[string]string{"HTTP_ASSERT_MAPHOST": real}, "--assert-ok", target) + assertExit(t, r, exitOK) + }) +} diff --git a/e2e_harness_test.go b/e2e_harness_test.go new file mode 100644 index 0000000..3bab0f3 --- /dev/null +++ b/e2e_harness_test.go @@ -0,0 +1,193 @@ +package main_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +// The end-to-end suite drives the compiled binary as a subprocess and asserts on +// its externally observable contract only: exit code, stdout, stderr. +// +// It deliberately avoids reaching into package internals. Every test here must +// keep passing verbatim across the planned rearchitecture (#54 viper removal, +// #55 run() extraction, #56 assertion constructors), which is the property that +// makes those refactors safe to perform. + +var ( + binPath string + buildErr error + buildOne sync.Once +) + +// coverDir collects coverage counters emitted by the subprocesses. `just +// test-cover` sets E2E_COVERDIR and merges the result with the unit-test +// profile; when it is unset the suite still runs, just without instrumentation. +func coverDir() string { return os.Getenv("E2E_COVERDIR") } + +// binary compiles the CLI once per test run and returns its path. It is built +// with -cover whenever E2E_COVERDIR is set so that subprocess execution counts +// toward the reported coverage. +func binary(t *testing.T) string { + t.Helper() + + buildOne.Do(func() { + dir, err := os.MkdirTemp("", "http-assert-e2e") + if err != nil { + buildErr = err + return + } + + bin := filepath.Join(dir, "http-assert") + if isWindows() { + bin += ".exe" + } + + args := []string{"build"} + if coverDir() != "" { + args = append(args, "-cover") + } + args = append(args, "-o", bin, ".") + + cmd := exec.Command("go", args...) + if out, err := cmd.CombinedOutput(); err != nil { + buildErr = fmt.Errorf("go %s: %w\n%s", strings.Join(args, " "), err, out) + return + } + binPath = bin + }) + + if buildErr != nil { + t.Fatalf("cannot build the CLI under test: %s", buildErr) + } + + return binPath +} + +func isWindows() bool { return os.PathSeparator == '\\' } + +// result is everything a caller of the CLI can observe. +type result struct { + ExitCode int + Stdout string + Stderr string +} + +// Output is stdout and stderr concatenated, for assertions that do not care +// which stream carried the text. +func (r result) Output() string { return r.Stdout + r.Stderr } + +// run executes the CLI with the given environment overlay and arguments. +// +// The inherited environment is scrubbed of every variable that could change the +// result -- HTTP_ASSERT_* from a developer's shell, and the proxy variables that +// http.ProxyFromEnvironment honours -- so a test asserts on what it sets and +// nothing else. env entries are applied on top of that clean base; a nil map +// means "no environment configuration at all". +func run(t *testing.T, env map[string]string, args ...string) result { + t.Helper() + + cmd := exec.Command(binary(t), args...) + cmd.Env = testEnv(env) + + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + res := result{Stdout: stdout.String(), Stderr: stderr.String()} + switch e := err.(type) { + case nil: + res.ExitCode = 0 + case *exec.ExitError: + res.ExitCode = e.ExitCode() + default: + t.Fatalf("cannot run the CLI: %s", err) + } + + return res +} + +func testEnv(overlay map[string]string) []string { + var out []string + for _, kv := range os.Environ() { + k, _, _ := strings.Cut(kv, "=") + if strings.HasPrefix(k, "HTTP_ASSERT_") || isProxyVar(k) { + continue + } + out = append(out, kv) + } + + if d := coverDir(); d != "" { + out = append(out, "GOCOVERDIR="+d) + } + + for k, v := range overlay { + out = append(out, k+"="+v) + } + + return out +} + +func isProxyVar(name string) bool { + switch strings.ToLower(name) { + case "http_proxy", "https_proxy", "all_proxy", "no_proxy": + return true + } + return false +} + +// Exit codes the CLI is contracted to return. Nothing in the repo documented +// these before this suite existed; see #24. +const ( + exitOK = 0 // every assertion passed + exitBadFlagVal = 71 // a flag value failed to parse (--log-level, --maphost) + exitBadRequest = 91 // the request could not be constructed (-X, URL) + exitRequestFail = 93 // transport failure, or at least one assertion failed + exitUsage = 103 // wrong argument count, unknown flag +) + +// assertExit fails the test with full context when the exit code is unexpected. +// Printing both streams matters: a wrong code is almost always explained by +// output the assertion itself does not look at. +func assertExit(t *testing.T, got result, want int) { + t.Helper() + if got.ExitCode != want { + t.Fatalf("exit code = %d, want %d\n--- stdout ---\n%s\n--- stderr ---\n%s", + got.ExitCode, want, got.Stdout, got.Stderr) + } +} + +// characterizes marks a test as pinning behavior that is currently *wrong* and +// tracked by the given GitHub issue. It is not a skip: the assertion still runs +// and must still pass. When the issue is fixed the test fails, which is the +// signal to update the expectation here in the same commit. +// +// Tagging (rather than only commenting) makes the whole set selectable: +// +// go test -run 'TestE2E.*/characterizes' ./... +func characterizes(t *testing.T, issue int, behavior string) { + t.Helper() + t.Logf("characterizes #%d: %s (expected to fail once the issue is fixed)", issue, behavior) +} + +func assertContains(t *testing.T, got result, want string) { + t.Helper() + if !strings.Contains(got.Output(), want) { + t.Fatalf("output does not contain %q\n--- stdout ---\n%s\n--- stderr ---\n%s", + want, got.Stdout, got.Stderr) + } +} + +func assertNotContains(t *testing.T, got result, unwanted string) { + t.Helper() + if strings.Contains(got.Output(), unwanted) { + t.Fatalf("output unexpectedly contains %q\n--- stdout ---\n%s\n--- stderr ---\n%s", + unwanted, got.Stdout, got.Stderr) + } +} diff --git a/e2e_known_issues_test.go b/e2e_known_issues_test.go new file mode 100644 index 0000000..0436496 --- /dev/null +++ b/e2e_known_issues_test.go @@ -0,0 +1,247 @@ +package main_test + +import ( + "strings" + "testing" +) + +// This file pins behaviour that is currently WRONG. Each test asserts what the +// tool does today and names the issue tracking the defect. +// +// These are not skipped. When an issue is fixed the corresponding test fails, +// which is the signal to update the expectation in the same commit that lands +// the fix. Select them all with: +// +// go test -run 'TestKnown' ./... +// +// exitPanic is not part of the documented contract -- it is what a Go panic +// produces, and reaching it is itself the bug (#17). +const exitPanic = 2 + +// TestKnownIssue17RegexPanic: an invalid regexp crashes with a stack trace +// instead of reporting an invalid flag value. +func TestKnownIssue17RegexPanic(t *testing.T) { + for _, flag := range []string{"--assert-body", "--assert-header", "--assert-redirect"} { + t.Run(flag, func(t *testing.T) { + characterizes(t, 17, flag+" panics on an unparseable pattern") + + arg := "[unclosed" + if flag == "--assert-header" { + arg = "X-Any: (unclosed" + } + r := run(t, nil, flag, arg, url("/ok")) + + assertExit(t, r, exitPanic) + assertContains(t, r, "panic: regexp: Compile") + assertContains(t, r, "goroutine 1 [running]:") + }) + } +} + +// TestKnownIssue18PayloadTruncated: the response dump replaces the body with a +// rendered form but leaves Content-Length untouched, so http.Response.Write +// truncates whatever it renders to the original byte count. +func TestKnownIssue18PayloadTruncated(t *testing.T) { + t.Run("silent mode truncates the placeholder", func(t *testing.T) { + characterizes(t, 18, "'<< Payload is omitted >>' is cut to the body's length") + + // The body is "boom" (4 bytes), so only 4 bytes of the placeholder survive. + r := run(t, nil, "-s", "--assert-ok", url("/500")) + assertExit(t, r, exitRequestFail) + assertContains(t, r, " <<") + assertNotContains(t, r, "Payload is omitted >>") + }) + + t.Run("binary body truncates the hex dump", func(t *testing.T) { + characterizes(t, 18, "an 8-byte binary body renders as a single hex offset") + + r := run(t, nil, "--assert-body-eq", "never-matches", url("/binary")) + assertExit(t, r, exitRequestFail) + assertContains(t, r, "00000000") + // The real dump would carry the bytes and an ASCII gutter. + assertNotContains(t, r, "|") + }) +} + +// TestKnownIssue19RequestBodyMissing: the request is dumped after the transport +// has drained its body, so Content-Length advertises bytes that are not shown. +func TestKnownIssue19RequestBodyMissing(t *testing.T) { + characterizes(t, 19, "the request dump omits the -d payload it advertises") + + r := run(t, nil, "-X", "POST", "-d", "sent-but-not-shown", "--assert-status", "999", url("/echo")) + assertExit(t, r, exitRequestFail) + + // The echoed response proves the body reached the server... + assertContains(t, r, "sent-but-not-shown") + // ...while the request dump advertises its length and shows nothing. + assertContains(t, r, "Content-Length: 18") + + reqDump, _, _ := strings.Cut(r.Output(), "HTTP/1.1 200 OK") + if strings.Contains(reqDump, "sent-but-not-shown") { + t.Fatal("request dump now includes the body; #19 is fixed -- update this test") + } +} + +// TestKnownIssue20AssertOkAcceptsRedirects: --assert-ok is documented as "2xx" +// but implemented as 200-399. +func TestKnownIssue20AssertOkAcceptsRedirects(t *testing.T) { + for _, path := range []string{"/redirect", "/redirect-rel"} { + t.Run(path, func(t *testing.T) { + characterizes(t, 20, "--assert-ok passes on a 3xx despite the docs saying 2xx") + assertExit(t, run(t, nil, "--assert-ok", url(path)), exitOK) + }) + } +} + +// TestKnownIssue22EmptyBodyEqualsNeverPasses: --assert-body-eq "" short-circuits +// on an empty body and reports that the empty string is "missing". +func TestKnownIssue22EmptyBodyEqualsNeverPasses(t *testing.T) { + characterizes(t, 22, `--assert-body-eq "" cannot pass, even against a 204`) + + r := run(t, nil, "--assert-body-eq", "", url("/empty")) + assertExit(t, r, exitRequestFail) + assertContains(t, r, `body: expected "", missing`) +} + +// TestKnownIssue23WildcardMaphostUnreachable: hostMapping.Matches handles "*" +// and "*:*", but the parser rejects both, so the branches are dead code. +func TestKnownIssue23WildcardMaphostUnreachable(t *testing.T) { + for _, src := range []string{"*", "*:*"} { + t.Run(src, func(t *testing.T) { + characterizes(t, 23, "the parser rejects a wildcard the matcher supports") + + r := run(t, nil, "--maphost", src+"="+hostPort(), "--assert-ok", "http://mapped.invalid/ok") + assertExit(t, r, exitBadFlagVal) + assertContains(t, r, "Invalid value for --maphost flag") + }) + } +} + +// TestKnownIssue25NoAssertionsIsNotAUsageError: the condition is detected before +// any request is made, yet reported with the transport-failure code. +func TestKnownIssue25NoAssertionsIsNotAUsageError(t *testing.T) { + characterizes(t, 25, "a usage error is reported as 'Cannot perform request' with exit 93") + + r := run(t, nil, url("/ok")) + assertExit(t, r, exitRequestFail) // ought to be exitUsage + assertContains(t, r, "Cannot perform request: no assertions defined") +} + +// TestKnownIssue26MaphostErrorDiscarded: parseHostMappings produces a precise +// message that mustParseHostMappings throws away. +func TestKnownIssue26MaphostErrorDiscarded(t *testing.T) { + characterizes(t, 26, "the specific parse error is replaced by the raw flag slice") + + r := run(t, nil, "--maphost", "garbage", "--assert-ok", url("/ok")) + assertExit(t, r, exitBadFlagVal) + assertContains(t, r, "Invalid value for --maphost flag: [garbage]") + assertNotContains(t, r, "has no separator") +} + +// TestKnownIssue27GzipBreaksBodyAssertions: setting Accept-Encoding by hand +// disables Go's transparent decompression, so assertions see compressed bytes. +func TestKnownIssue27GzipBreaksBodyAssertions(t *testing.T) { + t.Run("transparent decompression by default", func(t *testing.T) { + assertExit(t, run(t, nil, "--assert-body", `"status":"success"`, url("/gzip")), exitOK) + }) + + t.Run("caller-set Accept-Encoding breaks it", func(t *testing.T) { + characterizes(t, 27, "body assertions run against gzip bytes, with no warning") + + r := run(t, nil, "-H", "Accept-Encoding: gzip", "--assert-body", `"status":"success"`, url("/gzip")) + assertExit(t, r, exitRequestFail) + assertContains(t, r, "Content-Encoding: gzip") + }) +} + +// TestKnownIssue28DataDoesNotImplyPost: curl switches to POST for -d; this does not. +func TestKnownIssue28DataDoesNotImplyPost(t *testing.T) { + characterizes(t, 28, "-d keeps the GET method its help text says it changes") + + r := run(t, nil, "-d", "payload", "--assert-body-eq", "never-matches", url("/echo")) + assertContains(t, r, `\"method\":\"GET\"`) +} + +// TestKnownIssue31MaxTimeAcceptsNonPositive: values <= 0 reach http.Client.Timeout, +// where they mean "no timeout" rather than being rejected. +func TestKnownIssue31MaxTimeAcceptsNonPositive(t *testing.T) { + for _, v := range []string{"0", "-5"} { + t.Run("max-time "+v, func(t *testing.T) { + characterizes(t, 31, "a non-positive --max-time silently disables the timeout") + assertExit(t, run(t, nil, "-m", v, "--assert-ok", url("/ok")), exitOK) + }) + } +} + +// TestKnownIssue32NegatedBooleanFlagsDiffer: --assert-ok=false registers the +// inverse assertion; --assert-body-empty=false registers nothing. +func TestKnownIssue32NegatedBooleanFlagsDiffer(t *testing.T) { + t.Run("assert-ok=false asserts NOT ok", func(t *testing.T) { + characterizes(t, 32, "an undocumented negation that happens to be useful") + assertExit(t, run(t, nil, "--assert-ok=false", url("/500")), exitOK) + }) + + t.Run("assert-body-empty=false registers nothing", func(t *testing.T) { + characterizes(t, 32, "the same syntax on a sibling flag is a no-op") + r := run(t, nil, "--assert-body-empty=false", url("/ok")) + assertExit(t, r, exitRequestFail) + assertContains(t, r, "no assertions defined") + }) +} + +// TestKnownIssue33BareHeaderSendsEmptyValue: a -H value with no colon parses to +// an empty value and is sent as an empty-valued header. +// +// curl treats `-H 'X-Foo'` as "remove this header" and requires `-H 'X-Foo;'` to +// send an empty one, so the divergence is real -- but the header is NOT dropped, +// which is what #33 originally claimed. The issue text has been corrected. +func TestKnownIssue33BareHeaderSendsEmptyValue(t *testing.T) { + characterizes(t, 33, "-H without a colon sends an empty-valued header rather than being rejected") + + r := run(t, nil, "-H", "BareHeader", "--assert-body-eq", "never-matches", url("/echo")) + assertExit(t, r, exitRequestFail) + + // Canonicalised to Bareheader, present, with an empty value. + assertContains(t, r, "Bareheader") + assertContains(t, r, `\"Bareheader\":[\"\"]`) +} + +// TestKnownIssue34StdoutAlwaysEmpty: every byte goes to stderr, so redirecting +// stdout captures nothing. +func TestKnownIssue34StdoutAlwaysEmpty(t *testing.T) { + characterizes(t, 34, "results are on stderr; stdout is never written to") + + for _, tc := range []struct { + name string + args []string + }{ + {"on success", []string{"--assert-ok", url("/ok")}}, + {"on failure", []string{"--assert-ok", url("/500")}}, + } { + t.Run(tc.name, func(t *testing.T) { + r := run(t, nil, tc.args...) + if r.Stdout != "" { + t.Fatalf("stdout is no longer empty (%q); #34 is fixed -- update this test", r.Stdout) + } + if r.Stderr == "" { + t.Fatal("stderr unexpectedly empty") + } + }) + } +} + +// TestKnownIssueWarnLevelIsDead: nothing logs at warn, so --log-level warn is +// byte-identical to --log-level error. The dead logWarn/logError helpers are +// silenced with //nolint:unused rather than removed. +func TestKnownIssueWarnLevelIsDead(t *testing.T) { + characterizes(t, 0, "--log-level warn behaves exactly like error; nothing logs at warn") + + warn := run(t, nil, "--log-level", "warn", "--assert-ok", url("/ok")) + fail := run(t, nil, "--log-level", "error", "--assert-ok", url("/ok")) + + if warn.Output() != fail.Output() { + t.Fatalf("warn and error now differ; the level gained meaning -- update this test\nwarn: %q\nerror: %q", + warn.Output(), fail.Output()) + } + assertExit(t, warn, exitOK) +} diff --git a/e2e_server_test.go b/e2e_server_test.go new file mode 100644 index 0000000..002fa39 --- /dev/null +++ b/e2e_server_test.go @@ -0,0 +1,171 @@ +package main_test + +import ( + "compress/gzip" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + "time" +) + +// The e2e suite talks to two real servers started once per test run: a plain +// HTTP one and a TLS one with a self-signed certificate (which is what makes +// --insecure observable). Subprocesses reach them over the loopback interface. +var ( + srv *httptest.Server + tlsSrv *httptest.Server +) + +func TestMain(m *testing.M) { + srv = httptest.NewServer(testHandler()) + tlsSrv = httptest.NewTLSServer(http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusOK, []byte("secure-ok"), nil) + })) + + code := m.Run() + + srv.Close() + tlsSrv.Close() + os.Exit(code) +} + +// url builds an absolute URL against the plain HTTP test server. +func url(path string) string { return srv.URL + path } + +// hostPort is the test server's authority, for --maphost destinations. +func hostPort() string { return srv.Listener.Addr().String() } + +func testHandler() http.Handler { + mux := http.NewServeMux() + + // A well-formed JSON response carrying the headers the assertion tests match on. + mux.HandleFunc("/ok", func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusOK, []byte(`{"status":"success","users":[]}`), http.Header{ + "Content-Type": {"application/json"}, + "X-Api-Version": {"v1"}, + "Cache-Control": {"max-age=3600"}, + }) + }) + + mux.HandleFunc("/created", func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusCreated, []byte("created"), nil) + }) + + // 204 carries no body at all, which is what --assert-body-empty needs. + mux.HandleFunc("/empty", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + mux.HandleFunc("/500", func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusInternalServerError, []byte("boom"), nil) + }) + + mux.HandleFunc("/redirect", func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusFound, nil, http.Header{ + "Location": {"https://new-domain.com/path"}, + }) + }) + + // A relative Location, which the CLI compares verbatim rather than resolving. + mux.HandleFunc("/redirect-rel", func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusMovedPermanently, nil, http.Header{"Location": {"/target"}}) + }) + + // Two values under one header name, for the multi-value matching path. + mux.HandleFunc("/multi", func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusOK, []byte("ok"), http.Header{"Set-Cookie": {"a=1", "b=2"}}) + }) + + // Non-printable bytes, which routes the failure dump through the hex dumper. + mux.HandleFunc("/binary", func(w http.ResponseWriter, _ *http.Request) { + write(w, http.StatusOK, []byte{0x00, 0x01, 0x02, 0x03, 0xff, 0xfe, 0x07, 0x08}, nil) + }) + + // Larger than the 256-byte crop threshold used when printing payloads. + mux.HandleFunc("/big", func(w http.ResponseWriter, _ *http.Request) { + body := make([]byte, 5000) + for i := range body { + body[i] = 'X' + } + write(w, http.StatusOK, body, nil) + }) + + // Sleeps so --max-time has something to time out against. Kept short; the + // timeout tests use 1s and this only has to outlast it. + mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { + d := 2 * time.Second + if ms, err := strconv.Atoi(r.URL.Query().Get("ms")); err == nil { + d = time.Duration(ms) * time.Millisecond + } + time.Sleep(d) + write(w, http.StatusOK, []byte("slow"), nil) + }) + + // Compresses only when the client asked, so the suite can exercise both the + // transparent-decompression path and the caller-set-the-header path. + mux.HandleFunc("/gzip", func(w http.ResponseWriter, r *http.Request) { + raw := []byte(`{"status":"success"}`) + if !acceptsGzip(r) { + write(w, http.StatusOK, raw, nil) + return + } + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(http.StatusOK) + zw := gzip.NewWriter(w) + _, _ = zw.Write(raw) + _ = zw.Close() + }) + + // Reflects the request so tests can observe -X, -H and -d taking effect. + mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) { + body := make([]byte, 0, 512) + if r.Body != nil { + buf := make([]byte, 512) + for { + n, err := r.Body.Read(buf) + body = append(body, buf[:n]...) + if err != nil { + break + } + } + } + payload, _ := json.Marshal(map[string]any{ + "method": r.Method, + "body": string(body), + "headers": r.Header, + "host": r.Host, + }) + write(w, http.StatusOK, payload, http.Header{"Content-Type": {"application/json"}}) + }) + + return mux +} + +func acceptsGzip(r *http.Request) bool { + for _, v := range r.Header.Values("Accept-Encoding") { + if v == "gzip" || v == "gzip, deflate" { + return true + } + } + return false +} + +// write sends a complete response with an explicit Content-Length, so the +// framing the CLI sees is deterministic across Go versions. +func write(w http.ResponseWriter, code int, body []byte, hdrs http.Header) { + for k, vs := range hdrs { + for _, v := range vs { + w.Header().Add(k, v) + } + } + w.Header().Set("Content-Length", fmt.Sprint(len(body))) + w.WriteHeader(code) + if len(body) > 0 { + _, _ = w.Write(body) + } +}