From a39156b749202660f6f9e835e781852be11d80df Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:47:26 -0400 Subject: [PATCH 1/2] fix(api): Normalize assertion outcomes Guarantee that Client outcomes contain either a failure or an evaluation error, never both, and stamp evaluation errors with the assertion family. Clarify that a body-read error leaves partial encoded bytes and no decoded payload. Co-Authored-By: OpenAI Codex (GPT-5) --- assertions.go | 33 ++++++++++++++++++++++++++------- assertions_test.go | 17 +++++++++++++++++ client.go | 18 +++++++++--------- client_test.go | 39 +++++++++++++++++++++++++++++++++++++++ doc.go | 6 ++++-- response.go | 13 ++++++++----- 6 files changed, 103 insertions(+), 23 deletions(-) diff --git a/assertions.go b/assertions.go index bea5520..6831a01 100644 --- a/assertions.go +++ b/assertions.go @@ -2,6 +2,7 @@ package httpassert import ( "context" + "errors" "fmt" "regexp" "strconv" @@ -38,7 +39,11 @@ type Assertion interface { // Kind names the family this assertion belongs to. Kind() AssertionKind - // Check reports (nil, nil) when the assertion holds. + // Check reports exactly one of three states: (nil, nil) when the assertion + // holds; (failure, nil) when the response does not satisfy it; or + // (nil, error) when no verdict could be reached. Implementations must not + // return both a failure and an error. Client.Do treats an error as + // authoritative if a custom implementation violates that contract. Check(res *Response) (*Failure, error) } @@ -89,15 +94,29 @@ type assertionFunc struct { func (a assertionFunc) Kind() AssertionKind { return a.kind } -// Check stamps the failure with the assertion's kind, so Kind() and -// Failure.Kind cannot disagree and no constructor has to repeat itself. +// Check normalizes the outcome so its structured kind cannot disagree with +// Kind() and no constructor has to repeat itself. func (a assertionFunc) Check(res *Response) (*Failure, error) { f, err := a.check(res) - if f != nil { - f.Kind = a.kind - } + return normalizeOutcome(a.kind, f, err) +} - return f, err +// normalizeOutcome stamps structured results with the assertion family and +// defensively preserves the interface's mutually exclusive result states. +// An evaluation error wins over a failure because it means no valid verdict +// exists to describe as Expected versus Actual. +func normalizeOutcome(kind AssertionKind, failure *Failure, err error) (*Failure, error) { + if err != nil { + var evaluation *EvaluationError + if errors.As(err, &evaluation) && evaluation != nil { + evaluation.Kind = kind + } + return nil, err + } + if failure != nil { + failure.Kind = kind + } + return failure, nil } func newAssertion(kind AssertionKind, check func(res *Response) (*Failure, error)) Assertion { diff --git a/assertions_test.go b/assertions_test.go index bb5c0e3..8c93a41 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -788,3 +788,20 @@ func Test_AssertionCheckSeparatesFailureFromError(t *testing.T) { } }) } + +func Test_AssertionCheckStampsEvaluationErrorKind(t *testing.T) { + t.Parallel() + + assertion := Must(AssertJQ(".healthy")) + failure, err := assertion.Check(jqResponse("not JSON")) + if failure != nil { + t.Fatalf("Failure = %+v, want nil for an evaluation error", failure) + } + var evaluation *EvaluationError + if !errors.As(err, &evaluation) { + t.Fatalf("error = %T %v, want *EvaluationError", err, err) + } + if evaluation.Code != EvaluationJSON || evaluation.Kind != assertion.Kind() { + t.Errorf("EvaluationError = %+v, want JSON error for %q assertion", evaluation, assertion.Kind()) + } +} diff --git a/client.go b/client.go index 35c65d6..aa3ef7c 100644 --- a/client.go +++ b/client.go @@ -41,10 +41,10 @@ const ( // satisfy the assertion. type EvaluationError struct { Code EvaluationErrorCode - Kind AssertionKind - Target string - Encoding string - Cause error + Kind AssertionKind // assertion family; Client and built-in assertions populate it + Target string // jq query or "" when the evaluation needs no subject + Encoding string // response content encoding for body-decode errors + Cause error // underlying decoder, JSON, context, or jq error } func (e *EvaluationError) Error() string { @@ -65,8 +65,10 @@ func (e *EvaluationError) Unwrap() error { return e.Cause } -// Outcome is the result of evaluating one assertion. Passed reports whether -// both Failure and Err are nil. +// Outcome is the result of evaluating one assertion. Client.Do guarantees that +// Failure and Err are not both set; an error from a custom assertion takes +// precedence over a simultaneously returned failure. Passed reports whether +// both are nil. type Outcome struct { Kind AssertionKind Failure *Failure @@ -151,9 +153,7 @@ func (c Client) Do(req *http.Request, assertions ...Assertion) (*Result, error) for _, assertion := range assertions { failure, checkErr := assertion.Check(httpRes) kind := assertion.Kind() - if failure != nil { - failure.Kind = kind - } + failure, checkErr = normalizeOutcome(kind, failure, checkErr) result.Outcomes = append(result.Outcomes, Outcome{ Kind: kind, Failure: failure, diff --git a/client_test.go b/client_test.go index 03449a7..fa06001 100644 --- a/client_test.go +++ b/client_test.go @@ -158,6 +158,42 @@ func TestClientDoPerformsOneRequestAndChecksEveryAssertionInOrder(t *testing.T) } } +func TestClientDoNormalizesCustomAssertionOutcome(t *testing.T) { + cause := errors.New("cannot decide") + assertion := testAssertion{kind: "custom", check: func(*Response) (*Failure, error) { + return &Failure{Code: FailureBodyEqual}, &EvaluationError{ + Code: EvaluationJQ, + Kind: "wrong", + Cause: cause, + } + }} + client := Client{HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: make(http.Header), + Body: http.NoBody, + Request: req, + }, nil + })}} + + result, err := client.Do(request(t), assertion) + if err != nil { + t.Fatalf("Do: %s", err) + } + outcome := result.Outcomes[0] + if outcome.Failure != nil { + t.Errorf("Failure = %+v, want nil when assertion also returned an error", outcome.Failure) + } + var evaluation *EvaluationError + if !errors.As(outcome.Err, &evaluation) { + t.Fatalf("Err = %T %v, want *EvaluationError", outcome.Err, outcome.Err) + } + if evaluation.Kind != assertion.Kind() || !errors.Is(evaluation, cause) { + t.Errorf("EvaluationError = %+v, want kind %q wrapping cause", evaluation, assertion.Kind()) + } +} + func TestClientDoUsesPackageDefaultClient(t *testing.T) { original := defaultHTTPClient t.Cleanup(func() { defaultHTTPClient = original }) @@ -227,6 +263,9 @@ func TestClientDoReturnsPartialResponseOnReadError(t *testing.T) { if got := string(result.Response.BodyBytes); got != "partial" { t.Errorf("partial body = %q", got) } + if result.Response.DecodeErr != nil { + t.Errorf("DecodeErr = %v, want nil because decoding was not attempted", result.Response.DecodeErr) + } if !body.closed { t.Error("response body was not closed after read error") } diff --git a/doc.go b/doc.go index a37ef67..2403e4e 100644 --- a/doc.go +++ b/doc.go @@ -10,6 +10,8 @@ // per assertion. Failures describe responses that did not satisfy an assertion; // evaluation errors describe assertions that could not reach a verdict. // -// Client.Do consumes and closes the response body. The decoded payload remains -// available as Result.Response.BodyBytes with the original HTTP metadata. +// Client.Do consumes and closes the response body. After a nil top-level error, +// the decoded payload remains available as Result.Response.BodyBytes with the +// original HTTP metadata. A body-read error instead leaves the partial encoded +// bytes in BodyBytes and returns a non-nil top-level error. package httpassert diff --git a/response.go b/response.go index 0cec283..6043004 100644 --- a/response.go +++ b/response.go @@ -18,13 +18,16 @@ import ( ) // Response is the response assertions inspect. Response.Body has already been -// consumed and closed; BodyBytes contains the decoded payload when DecodeErr is -// nil. The original headers, including Content-Encoding and Content-Length, -// remain unchanged. +// consumed and closed. After a successful Client.Do, BodyBytes contains the +// decoded payload when DecodeErr is nil. If Client.Do returns a body-read +// error, BodyBytes contains only the bytes read before that error and no +// decoding was attempted. The original headers, including Content-Encoding and +// Content-Length, remain unchanged. type Response struct { *http.Response - // BodyBytes is the complete decoded response payload when DecodeErr is - // nil. Client.Do reads it before evaluating any assertion. + // BodyBytes is the complete decoded response payload after a successful + // Client.Do. On a body-read error it is the partial encoded payload received + // before the error; callers must check Client.Do's top-level error first. BodyBytes []byte // Encoding is the response's Content-Encoding value, with surrounding // whitespace removed. An empty value means no encoding was declared. From 5066ef96bdee9cc63f6f597812d39452b0db0e2c Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:48:58 -0400 Subject: [PATCH 2/2] chore(release): Prepare v0.4.0 Pin Go 1.26.7, strengthen the tag gate with library race tests and govulncheck, and finalize the v0.4.0 documentation. Split setup-go inputs by matrix leg and move the resolved redirect behavior out of the known-issues suite. Co-Authored-By: OpenAI Codex (GPT-5) --- .github/workflows/build.yml | 10 ++++++++-- .github/workflows/release.yml | 13 +++++++++++-- CHANGELOG.md | 11 ++++++++--- README.md | 6 ++++-- cmd/http-assert/e2e_assert_test.go | 11 +++++++++++ cmd/http-assert/e2e_known_issues_test.go | 11 ----------- go.mod | 2 +- 7 files changed, 43 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 904a3c0..71646ab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,12 +84,18 @@ jobs: steps: - uses: actions/checkout@v7 - - name: Set up Go + - name: Set up Go from go.mod + if: matrix.go == '' uses: actions/setup-go@v7 with: - go-version: ${{ matrix.go }} go-version-file: go.mod + - name: Set up stable Go + if: matrix.go != '' + uses: actions/setup-go@v7 + with: + go-version: ${{ matrix.go }} + - name: Install just uses: extractions/setup-just@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc71086..cee8a07 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,12 +26,21 @@ jobs: - name: Install just uses: extractions/setup-just@v4 + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + # A tag can be cut from any commit, not only one that master already proved - # green. Twenty seconds here is cheaper than a published binary that does - # not work and a version number that cannot be reused. + # green. Rechecking both the library and binary here is cheaper than a + # published artifact that does not work and a version that cannot be reused. + - name: Run unit tests with the race detector + run: just test-race + - name: Run end-to-end tests run: just test-e2e + - name: Scan Go dependencies for known vulnerabilities + run: govulncheck ./... + # The release body is the tag's section of CHANGELOG.md, nothing else. This # fails when that section is missing or empty, which stops the release # before a single artifact is published -- the recoverable moment to find diff --git a/CHANGELOG.md b/CHANGELOG.md index a84b674..c5a977e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ are marked **Breaking** and listed first in their section. ## [Unreleased] +## [0.4.0] - 2026-08-30 + ### Added - A reusable Go package at `github.com/korya/http-assert` exposes the HTTP @@ -25,8 +27,10 @@ are marked **Breaking** and listed first in their section. `github.com/korya/http-assert/cmd/http-assert`; use `go install github.com/korya/http-assert/cmd/http-assert@latest`. Published release archives and the `http-assert` binary name are unchanged. -- Assertion failures and evaluation errors are structured library data. The - CLI owns human-readable formatting and preserves its existing output. +- Assertion failures and evaluation errors are structured library data. Each + outcome is exclusively a pass, a failed assertion or an evaluation error, + and its assertion family is consistently typed. The CLI owns human-readable + formatting and preserves its existing output. - Assertion families use the exported `AssertionKind` type and constants instead of requiring consumers to compare raw strings. - The library's zero-value client uses a 20-second total request timeout instead @@ -160,7 +164,8 @@ Conventional Commits, and the transfer from `PlanitarInc`. See the [git history](https://github.com/korya/http-assert/commits/v0.0.7) for that period. -[Unreleased]: https://github.com/korya/http-assert/compare/v0.3.0...HEAD +[Unreleased]: https://github.com/korya/http-assert/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/korya/http-assert/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/korya/http-assert/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/korya/http-assert/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/korya/http-assert/compare/v0.0.7...v0.1.0 diff --git a/README.md b/README.md index f0dc514..bd711cd 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ amd64 and arm64 are attached to every [release](https://github.com/korya/http-as ```bash # Pick the latest tag from https://github.com/korya/http-assert/releases -VERSION=v0.3.0 +VERSION=v0.4.0 OS=$(uname -s | tr '[:upper:]' '[:lower:]') ARCH=$(uname -m | sed 's/x86_64/amd64/; s/aarch64/arm64/') ARCHIVE="http-assert_${VERSION#v}_${OS}_${ARCH}.tar.gz" @@ -229,7 +229,9 @@ func CheckWithPolicy(ctx context.Context, url string) (*ha.Result, error) { } ``` -`Client.Do` consumes and closes the response body before returning. Use `Result.Response.BodyBytes` for the decoded payload rather than reading `Result.Response.Body`; HTTP status, headers and other `http.Response` metadata remain available. If content decoding fails, `DecodeErr` describes the problem and `BodyBytes` contains the encoded bytes as received. +`Client.Do` consumes and closes the response body before returning. After a nil top-level error, use `Result.Response.BodyBytes` for the decoded payload rather than reading `Result.Response.Body`; HTTP status, headers and other `http.Response` metadata remain available. If reading the body fails, `BodyBytes` contains only the bytes received before the error and no decoding was attempted. If content decoding fails, `DecodeErr` describes the problem and `BodyBytes` contains the encoded bytes as received. + +Response bodies are currently buffered completely in memory without a configurable limit, including decoded payloads that may be larger than their wire representation. See [#106](https://github.com/korya/http-assert/issues/106). The library sends one request and never retries. Retry policy, destination validation, logging and presentation intentionally remain application concerns. diff --git a/cmd/http-assert/e2e_assert_test.go b/cmd/http-assert/e2e_assert_test.go index 7578358..ade5294 100644 --- a/cmd/http-assert/e2e_assert_test.go +++ b/cmd/http-assert/e2e_assert_test.go @@ -41,6 +41,17 @@ func TestE2EAssertions(t *testing.T) { } } +// TestE2EAssertOKAcceptsRedirects keeps the intentional 2xx-or-3xx contract +// aligned with the flag help and README. Issue #20 corrected the documentation; +// this is ordinary regression coverage rather than a known defect. +func TestE2EAssertOKAcceptsRedirects(t *testing.T) { + for _, path := range []string{"/redirect", "/redirect-rel"} { + t.Run(path, func(t *testing.T) { + assertExit(t, run(t, nil, "--assert-ok", url(path)), exitOK) + }) + } +} + // 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) { diff --git a/cmd/http-assert/e2e_known_issues_test.go b/cmd/http-assert/e2e_known_issues_test.go index 6044ba2..1667e62 100644 --- a/cmd/http-assert/e2e_known_issues_test.go +++ b/cmd/http-assert/e2e_known_issues_test.go @@ -11,17 +11,6 @@ import "testing" // // go test -run 'TestKnown' ./... -// 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) - }) - } -} - // TestKnownIssue23WildcardMaphostUnreachable: hostMapping.Matches handles "*" // and "*:*", but the parser rejects both, so the branches are dead code. func TestKnownIssue23WildcardMaphostUnreachable(t *testing.T) { diff --git a/go.mod b/go.mod index 550f4d7..5290984 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/korya/http-assert go 1.26 -toolchain go1.26.5 +toolchain go1.26.7 require ( github.com/andybalholm/brotli v1.2.2