Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
30 changes: 24 additions & 6 deletions assertions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"

"github.com/itchyny/gojq"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
})
}
Expand Down Expand Up @@ -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
}
Expand Down
18 changes: 9 additions & 9 deletions assertions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions e2e_assert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 [`)
}
})
}
11 changes: 9 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading