Files
zip/detail_test.go
zeekay 3dd01043ec Answer refusals as RFC 9457 problem documents; OAuth keeps its own vocabulary
A refusal now leaves by one door and is written in the vocabulary of the
address it happened at.

HTTPError loses its struct tags and becomes a plain value; MarshalJSON is the
single place that says what it looks like. Its three members already mapped
onto both standards exactly, so no field was added: Status is `status`, Msg is
`detail`, Code is an extension member (RFC 9457), and the same value reads as
`error` / `error_description` under RFC 6749 §5.2. With() members still merge
at the top level per §3.2, with the document's own written last.

zip.OAuth(r) returns a Router whose routes answer the OAuth vocabulary. The
fact rides the route entry rather than a table of addresses, so it survives
composition, and it is a property of the address rather than of the returned
value — which is what makes it reach the refusals no handler wrote (Bind,
validation, panics). A verb the endpoint does not serve is the router
refusing, and answers a problem document with Allow.

middleware.Recover wrote its own 500 body with c.JSON, a second error surface
that spoke only the default vocabulary; it now returns the error so the one
handler renders it. group() copies oauth beside wrap, so a nested scope keeps
the vocabulary.

Allow on 405 comes from the fiber fork's router and was already correct;
TestAWrongVerbSaysWhichVerbsWork now pins it in both directions so an error
handler edit cannot drop it.

Consumers reading `body.error` read `body.detail` now. Each repo takes the
change when it moves its own pin.
2026-08-21 12:47:57 -07:00

71 lines
2.4 KiB
Go

package zip
import (
"encoding/json"
"testing"
)
// TestARefusalCarriesItsDetail is the capability six subsystems were missing: a
// typed op's only way to refuse is to return an error, so a non-2xx with a shape
// could not be typed at all.
func TestARefusalCarriesItsDetail(t *testing.T) {
err := ErrPaymentRequired("spend cap exceeded").
With(map[string]any{"cap": 5000, "spent": 5127, "ledger": "acme"})
raw, mErr := json.Marshal(err)
if mErr != nil {
t.Fatal(mErr)
}
var got map[string]any
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
if got["status"] != float64(402) || got["detail"] != "spend cap exceeded" {
t.Fatalf("problem document did not survive: %v", got)
}
// MERGED, not nested: a reader sees one object rather than a body filed
// under a key it has to know to look in — RFC 9457 §3.2.
for k, want := range map[string]any{"cap": float64(5000), "spent": float64(5127), "ledger": "acme"} {
if got[k] != want {
t.Errorf("detail %s = %v, want %v", k, got[k], want)
}
}
}
// TestDetailCannotDisplaceTheDocument is the half that matters more than the
// feature. A domain field named `status` silently overwriting the refusal's
// status is how a 402 reads as a 200 — so the document's own members are
// written last and win.
func TestDetailCannotDisplaceTheDocument(t *testing.T) {
err := ErrUnprocessable("build failed").
With(map[string]any{"status": 200, "detail": "everything is fine", "code": "ok", "type": "https://evil"})
err.Code = "build_failed"
raw, _ := json.Marshal(err)
var got map[string]any
_ = json.Unmarshal(raw, &got)
for member, want := range map[string]any{
"status": float64(422),
"detail": "build failed",
"code": "build_failed",
"type": "about:blank",
} {
if got[member] != want {
t.Errorf("%s = %v, want %v — an extension member displaced the refusal", member, got[member], want)
}
}
}
// TestWithMerges pins that two callers adding one fact each both land — a gate
// naming the cap and a meter naming the ledger are two facts about one refusal.
func TestWithMerges(t *testing.T) {
e := ErrPaymentRequired("no").With(map[string]any{"cap": 1}).With(map[string]any{"ledger": "acme"})
if e.Detail["cap"] != 1 || e.Detail["ledger"] != "acme" {
t.Errorf("second With replaced the first: %v", e.Detail)
}
if ErrNotFound("x").With(nil).Detail != nil {
t.Error("With(nil) allocated")
}
}