diff --git a/docs/PRINCIPLES.md b/docs/PRINCIPLES.md index 06fdeb6..9376b4f 100644 --- a/docs/PRINCIPLES.md +++ b/docs/PRINCIPLES.md @@ -71,9 +71,15 @@ a **single portable artifact** you can move between machines and sessions. Large are fine — Smocker handles them. ([#161]) ### 3.4 Matching is faithful to real HTTP, not lenient -- Header and query-parameter matching is **case-sensitive by design**. Real servers in the - wild are case-sensitive (e.g. AWS API Gateway), and being lenient would hide real bugs in - the system under test. ([#179]) +- **Header names** are matched **case-insensitively** (RFC 7230 §3.2: header field names are + case-insensitive), so a mock declaring `content-type` matches a request's `Content-Type`. + **Header values** and **query-parameter names** stay **case-sensitive by design**: real + servers are case-sensitive there (e.g. AWS API Gateway), and being lenient would hide real + bugs in the system under test. ([#179], [#281]) +- Smocker never rewrites the casing it is given: response headers are returned with the exact + case the mock declares (or the upstream sends, for proxies). Note that Go's `net/http` + canonicalizes header names when it parses an incoming request or an upstream proxy response, + so those reach Smocker already canonicalized regardless. - The default matcher is `ShouldEqual`; a bare string is shorthand for it. The matcher set (`ShouldMatch`, `ShouldContainSubstring`, `ShouldEqualJSON`, `ShouldNotBeEmpty`, …) is the vocabulary — prefer extending existing semantics over inventing parallel ones. @@ -166,6 +172,7 @@ These are recognized gaps, not oversights — worth improving, but not regressio [#161]: https://github.com/smocker-dev/smocker/issues/161 [#162]: https://github.com/smocker-dev/smocker/issues/162 [#179]: https://github.com/smocker-dev/smocker/issues/179 +[#281]: https://github.com/smocker-dev/smocker/issues/281 [#219]: https://github.com/smocker-dev/smocker/issues/219 [#231]: https://github.com/smocker-dev/smocker/issues/231 [#266]: https://github.com/smocker-dev/smocker/issues/266 diff --git a/server/types/matchers.go b/server/types/matchers.go index 0db6ce1..ab742ea 100644 --- a/server/types/matchers.go +++ b/server/types/matchers.go @@ -249,6 +249,22 @@ func (mmm MultiMapMatcher) Match(values map[string][]string) bool { return true } +// MatchHeaders is like Match but resolves header names case-insensitively (RFC 7230 §3.2: header +// field names are case-insensitive), so a mock declaring "content-type" matches a request's +// "Content-Type". Header *values* are still matched exactly. The declared casing is never altered — +// http.Header.Values only canonicalizes the key it looks up with, not the stored keys. +func (mmm MultiMapMatcher) MatchHeaders(headers http.Header) bool { + if len(mmm) > len(headers) { + return false + } + for key, matcherValue := range mmm { + if value := headers.Values(key); len(value) == 0 || !matcherValue.Match(value) { + return false + } + } + return true +} + type BodyMatcher struct { bodyString *StringMatcher bodyJson map[string]StringMatcher diff --git a/server/types/matchers_test.go b/server/types/matchers_test.go index c05352b..c6c4f34 100644 --- a/server/types/matchers_test.go +++ b/server/types/matchers_test.go @@ -2,12 +2,52 @@ package types import ( "encoding/json" + "net/http" "reflect" "testing" "gopkg.in/yaml.v3" ) +func mapMatcher(t *testing.T, y string) MultiMapMatcher { + t.Helper() + var mmm MultiMapMatcher + if err := yaml.Unmarshal([]byte(y), &mmm); err != nil { + t.Fatalf("invalid matcher yaml: %v", err) + } + return mmm +} + +// TestMatchHeadersCaseInsensitive covers #281: header *names* match case-insensitively (RFC 7230), +// while header *values* — and query-parameter names via Match — stay case-sensitive. +func TestMatchHeadersCaseInsensitive(t *testing.T) { + // A mock declaring a lowercase name matches the canonical request header, and vice versa. + req := http.Header{} + req.Set("Content-Type", "application/json") // net/http canonicalizes incoming names + + if !mapMatcher(t, "content-type: application/json").MatchHeaders(req) { + t.Error(`"content-type" should match request header "Content-Type"`) + } + if !mapMatcher(t, "Content-Type: application/json").MatchHeaders(req) { + t.Error(`"Content-Type" should match request header "Content-Type"`) + } + + // Header values remain case-sensitive. + if mapMatcher(t, "Content-Type: application/json").MatchHeaders(http.Header{"Content-Type": {"Application/JSON"}}) { + t.Error("header values must stay case-sensitive") + } + + // A missing header still fails to match. + if mapMatcher(t, "X-Absent: x").MatchHeaders(req) { + t.Error("a header absent from the request must not match") + } + + // Query-parameter matching (Match) stays case-sensitive on names. + if mapMatcher(t, "Token: abc").Match(map[string][]string{"token": {"abc"}}) { + t.Error("query-parameter names must stay case-sensitive") + } +} + func TestStringMatcherJSON(t *testing.T) { test := `"test"` serialized := `{"matcher":"ShouldEqual","value":"test"}` diff --git a/server/types/mock.go b/server/types/mock.go index 93eb784..d8cf00c 100644 --- a/server/types/mock.go +++ b/server/types/mock.go @@ -120,7 +120,7 @@ func (mr MockRequest) Match(req Request) bool { slog.Debug("Path did not match") return false } - matchHeaders := mr.Headers == nil || mr.Headers.Match(req.Headers) + matchHeaders := mr.Headers == nil || mr.Headers.MatchHeaders(req.Headers) if !matchHeaders { slog.Debug("Headers did not match") return false