From 29f7a5a8a109c2dcffb8ce532065dd12e3969070 Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:29:27 -0400 Subject: [PATCH] fix(assert): Render header values readably; document multi-value A failed header assertion printed Go's slice syntax at the user: a header that only ever carried one value came back as `got ["abc123"]`, which reads as though something bracketed had happened to it. Values are now written the way a person writes them -- `got "abc123"` for one, `got "first", "second"` for several. The bracket was the only place the tool admitted that a header is a list. --assert-header and --assert-header-eq hold when ANY value matches, so --assert-header-eq 'Set-Cookie: session=abc' passes against a response setting three cookies. That is the right default and is not changing, but "equals" and the README's "exact value" both read as a claim about the only value, and nothing in --help or the README mentioned multiple values at all. Both now say so, next to the paragraph about repeating the flag, which is a different thing and was the only nearby text a reader could mistake for this one. The rendering was wrong in three assertions rather than the one reported: --assert-header and --assert-header-missing formatted the same []string the same way. Fixing only the reported flag would have left two assertions disagreeing with it. Closes #97 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrknafJSP5hF8u865cbnqX --- README.md | 14 +++++++++-- assertions.go | 30 ++++++++++++++++++----- assertions_test.go | 18 +++++++------- e2e_assert_test.go | 61 ++++++++++++++++++++++++++++++++++++++++++++++ main.go | 11 +++++++-- 5 files changed, 115 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4cb452e..71f2379 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,8 @@ A `-H` value needs a colon. A bare name exits `71` rather than being sent as a h |------|-------------| | `--assert-ok` | Assert the status is not an error (2xx or 3xx) | | `--assert-status` | Assert specific status code | -| `--assert-header` | Assert header matches regex pattern; a name alone asserts presence | -| `--assert-header-eq` | Assert header equals exact value; a name alone asserts presence | +| `--assert-header` | Assert any value of the header matches a regex; a name alone asserts presence | +| `--assert-header-eq` | Assert any value of the header equals the given value; a name alone asserts presence | | `--assert-header-missing` | Assert header is not present | | `--assert-body` | Assert body matches regex pattern | | `--assert-body-eq` | Assert body equals exact value | @@ -173,6 +173,16 @@ A `-H` value needs a colon. A bare name exits `71` rather than being sent as a h The three header flags and `--assert-jq` can be repeated to make several assertions of that kind. Every other assertion flag takes a single value; giving one twice exits `71` rather than silently keeping the last. +A response header can carry several values, which is a different thing from repeating the flag. `Set-Cookie` routinely does, and `--assert-header` and `--assert-header-eq` hold when **any** value matches: + +```console +# The response sets three cookies; this passes on the strength of one of them. +$ http-assert --assert-header-eq 'Set-Cookie: session=abc' https://example.com/login +[+] PASSED 41ms +``` + +That is usually what you want, and it is worth knowing before you read `--assert-header-eq` as a claim about the only value. `--assert-header-missing` is the strict one: it fails if the header carries any value at all. + `--assert-ok` and `--assert-body-empty` can be negated with `=false`, which asserts the opposite rather than cancelling the flag: ```bash diff --git a/assertions.go b/assertions.go index 259a7bc..a48de61 100644 --- a/assertions.go +++ b/assertions.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "regexp" + "strconv" + "strings" "time" "github.com/itchyny/gojq" @@ -82,6 +84,22 @@ func newAssertion(kind string, check func(res *httpResponse) (*Failure, error)) // response exists. They return an error rather than panicking; every other // constructor in this file is infallible and returns an Assertion directly. +// headerValues renders a header's values the way the response carried them. +// +// A header can appear more than once, so the values are a list -- but %q on a +// []string prints Go's own syntax, and `got ["abc123"]` told a user reading a +// failure about a single-valued header that something bracketed had happened +// to their value (#97). One value now reads as one value, and several read as +// a list a person would write. +func headerValues(vs []string) string { + quoted := make([]string, len(vs)) + for i, v := range vs { + quoted[i] = strconv.Quote(v) + } + + return strings.Join(quoted, ", ") +} + func AssertStatusOK() Assertion { return newAssertion("ok", func(res *httpResponse) (*Failure, error) { if s := res.StatusCode; s < 200 || s >= 400 { @@ -149,8 +167,8 @@ func AssertHeaderMissing(name string) Assertion { Target: name, Expected: "missing", Actual: vs, - Message: fmt.Sprintf("header[%s]: expected to be missing, got %q", - name, vs), + Message: fmt.Sprintf("header[%s]: expected to be missing, got %s", + name, headerValues(vs)), }, nil } @@ -180,8 +198,8 @@ func AssertHeaderEqual(name, expValue string) Assertion { Target: name, Expected: expValue, Actual: vs, - Message: fmt.Sprintf("header[%s]: expected %q, got %q", - name, expValue, vs), + Message: fmt.Sprintf("header[%s]: expected %q, got %s", + name, expValue, headerValues(vs)), }, nil }) } @@ -213,8 +231,8 @@ func AssertHeaderMatch(name, expPattern string) (Assertion, error) { Target: name, Expected: expPattern, Actual: vs, - Message: fmt.Sprintf("header[%s]: expected to match %q, got %q", - name, expPattern, vs), + Message: fmt.Sprintf("header[%s]: expected to match %q, got %s", + name, expPattern, headerValues(vs)), }, nil }), nil } diff --git a/assertions_test.go b/assertions_test.go index 679a9f4..5889e26 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -137,8 +137,8 @@ func Test_AssertHeader(t *testing.T) { "Target": []string{""}, "two": []string{"value", "v", "2"}, }, - ExpEqualError: `header[taRgET]: expected "value", got [""]`, - ExpMatchError: `header[taRget]: expected to match "(?i)^val.*$", got [""]`, + ExpEqualError: `header[taRgET]: expected "value", got ""`, + ExpMatchError: `header[taRget]: expected to match "(?i)^val.*$", got ""`, }, { CaseName: "Non-empty but non-matching value", @@ -147,8 +147,8 @@ func Test_AssertHeader(t *testing.T) { "Target": []string{"v"}, "two": []string{"value", "v", "2"}, }, - ExpEqualError: `header[taRgET]: expected "value", got ["v"]`, - ExpMatchError: `header[taRget]: expected to match "(?i)^val.*$", got ["v"]`, + ExpEqualError: `header[taRgET]: expected "value", got "v"`, + ExpMatchError: `header[taRget]: expected to match "(?i)^val.*$", got "v"`, }, { CaseName: "Matching value", @@ -157,7 +157,7 @@ func Test_AssertHeader(t *testing.T) { "Target": []string{"vAl"}, "two": []string{"value", "v", "2"}, }, - ExpEqualError: `header[taRgET]: expected "value", got ["vAl"]`, + ExpEqualError: `header[taRgET]: expected "value", got "vAl"`, }, { CaseName: "Exact value", @@ -175,8 +175,8 @@ func Test_AssertHeader(t *testing.T) { "Target": []string{"one", "two", "three"}, "two": []string{"value", "v", "2"}, }, - ExpEqualError: `header[taRgET]: expected "value", got ["one" "two" "three"]`, - ExpMatchError: `header[taRget]: expected to match "(?i)^val.*$", got ["one" "two" "three"]`, + ExpEqualError: `header[taRgET]: expected "value", got "one", "two", "three"`, + ExpMatchError: `header[taRget]: expected to match "(?i)^val.*$", got "one", "two", "three"`, }, { CaseName: "Multple: Matching value", @@ -185,7 +185,7 @@ func Test_AssertHeader(t *testing.T) { "Target": []string{"one", "two", "vAl", "three"}, "two": []string{"value", "v", "2"}, }, - ExpEqualError: `header[taRgET]: expected "value", got ["one" "two" "vAl" "three"]`, + ExpEqualError: `header[taRgET]: expected "value", got "one", "two", "vAl", "three"`, }, { CaseName: "Multple: Exact value", @@ -219,7 +219,7 @@ func Test_AssertHeader(t *testing.T) { checkErr(t, "present", check(present, res), "") // The values are echoed back, so match rather than pin them. checkErrMatch(t, "missing", check(missing, res), - `header\[taRgEt\]: expected to be missing, got \[.*\]$`) + `header\[taRgEt\]: expected to be missing, got ".*"$`) } checkErr(t, "equal", check(equal, res), tc.ExpEqualError) diff --git a/e2e_assert_test.go b/e2e_assert_test.go index 8bd2402..7578358 100644 --- a/e2e_assert_test.go +++ b/e2e_assert_test.go @@ -588,3 +588,64 @@ func TestE2EBadPatternIsRejected(t *testing.T) { assertExit(t, run(t, nil, "--assert-body", `"status":\s*"success"`, url("/ok")), exitOK) }) } + +// TestE2EHeaderMultiValue pins the semantics of a repeated response header and +// the way its values are rendered when an assertion fails (#97). +// +// A header can carry several values, which is a different thing from repeating +// the flag. The matching rule was already the behaviour; it was never written +// down anywhere a user would look, and the failure was the only hint -- in Go's +// own slice syntax. +func TestE2EHeaderMultiValue(t *testing.T) { + t.Run("any value satisfies --assert-header-eq", func(t *testing.T) { + for _, v := range []string{"a=1", "b=2"} { + assertExit(t, run(t, nil, "--assert-header-eq", "Set-Cookie: "+v, url("/multi")), exitOK) + } + }) + + t.Run("any value satisfies --assert-header", func(t *testing.T) { + for _, p := range []string{`^a=\d$`, `^b=\d$`} { + assertExit(t, run(t, nil, "--assert-header", "Set-Cookie: "+p, url("/multi")), exitOK) + } + }) + + // The strict one: a header carrying any value at all is not missing. + t.Run("--assert-header-missing fails on a multi-valued header", func(t *testing.T) { + r := run(t, nil, "--assert-header-missing", "Set-Cookie", url("/multi")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, `header[Set-Cookie]: expected to be missing, got "a=1", "b=2"`) + }) + + t.Run("a failure lists the values a person would write", func(t *testing.T) { + r := run(t, nil, "--assert-header-eq", "Set-Cookie: nope", url("/multi")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, `header[Set-Cookie]: expected "nope", got "a=1", "b=2"`) + }) + + // One value reads as one value. `got ["v1"]` said something bracketed had + // happened to a header that only ever had one value. + t.Run("a single-valued header is not rendered as a list", func(t *testing.T) { + r := run(t, nil, "--assert-header-eq", "X-Api-Version: nope", url("/ok")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, `header[X-Api-Version]: expected "nope", got "v1"`) + }) + + t.Run("--assert-header renders the same way", func(t *testing.T) { + r := run(t, nil, "--assert-header", `X-Api-Version: ^v9$`, url("/ok")) + assertExit(t, r, exitAssertFail) + assertContains(t, r, `header[X-Api-Version]: expected to match "^v9$", got "v1"`) + }) + + // The regression guard: no assertion may leak Go's slice syntax again. + t.Run("no failure leaks Go slice syntax", func(t *testing.T) { + for _, args := range [][]string{ + {"--assert-header-eq", "Set-Cookie: nope", url("/multi")}, + {"--assert-header", "Set-Cookie: ^nope$", url("/multi")}, + {"--assert-header-missing", "Set-Cookie", url("/multi")}, + {"--assert-header-eq", "X-Api-Version: nope", url("/ok")}, + } { + r := run(t, nil, args...) + assertNotContains(t, r, `got [`) + } + }) +} diff --git a/main.go b/main.go index 20bb34b..f12cda3 100644 --- a/main.go +++ b/main.go @@ -135,6 +135,13 @@ Repeat --assert-header, --assert-header-eq, --assert-header-missing or takes a single value and is rejected if given twice, rather than quietly keeping the last. +A response header can also carry several values -- Set-Cookie routinely does -- +and that is a different thing from repeating the flag. --assert-header and +--assert-header-eq hold when ANY value matches, so --assert-header-eq +'Set-Cookie: session=abc' passes on a response that also sets two other +cookies. --assert-header-missing is the strict one: it fails if the header +carries any value at all. + The two boolean assertions can be negated with =false, which selects the opposite assertion rather than cancelling the flag: --assert-ok=false asserts the status IS an error, and --assert-body-empty=false asserts the body is not @@ -649,9 +656,9 @@ func parseHostMappings(vals []string) ([]hostMapping, error) { func registerAssertionFlags(cmd *cobra.Command) { cmd.Flags().Int("assert-status", 0, "Assert response status equals the provided value") cmd.Flags().StringArray("assert-header", nil, - "Assert header matches the provided regexp; NAME alone asserts it is present") + "Assert any value of the header matches the provided regexp; NAME alone asserts it is present") cmd.Flags().StringArray("assert-header-eq", nil, - "Assert header equals the provided value; NAME alone asserts it is present") + "Assert any value of the header equals the provided value; NAME alone asserts it is present") cmd.Flags().StringArray("assert-header-missing", nil, "Assert header is missing") cmd.Flags().String("assert-body", "", "Assert body matches the provided regexp") cmd.Flags().String("assert-body-eq", "", "Assert body equals the provided value")