Files
zip/pathparam_test.go
T
zeekayandHanzo Dev 478e06d1d4 Authorization can hold an op, not only allow or refuse it
The hook answered allow-or-error, so an operation that needs a person to
sign it off had nowhere to go but a refusal. A caller told 403 stops; a
caller told the request is held waits, and those are different futures.

An Authorizer now answers a Decision — Allow, Deny or Approve, with the
clause that reached it and the reason a person reads — resolved ONCE in
the run() contract that op.invoke and op.direct both funnel through. The
projections only render it, so all six agree without a branch in any of
them: REST answers 403 or 202, the call plane the same two over ZAP, MCP
carries the held body as content rather than an error because nothing
failed, the CLI prints it, the graph reports it beside the field it was
selected for, and a Go caller reads it off the error lane with HeldOf.
An Approval has no Out and never will have one — the handler did not run
— which is the whole reason it does not travel the value lane there.

Clause, not rule. This package already spells an Authorizer "the rule"
— App.rule, adopt, "two rules that disagree" — so the token naming which
part of it fired needs its own word, and a rule has clauses.

The error return survives for a genuine failure of the CHECK: an
authority that cannot be reached has not decided anything, and answering
503 rather than 403 is what lets a client retry.

The op carries the question rather than the app, because they differ. A
composed child answers to whatever included it, and a mounted op has no
local rule at all — so the document publishes the held body exactly where
the seam would produce one, and a gated service's OpenAPI reads as the
pair a generated client needs.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-04 00:16:37 -07:00

300 lines
10 KiB
Go

package zip_test
import (
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// memberIn is a member-route input: identity in the path, payload in the body.
type memberIn struct {
Owner string `json:"owner"`
Name string `json:"name"`
Note string `json:"note"`
}
type memberOut struct {
Owner string `json:"owner"`
Name string `json:"name"`
Note string `json:"note"`
}
func echoMember(_ context.Context, in *memberIn) (*memberOut, error) {
return &memberOut{Owner: in.Owner, Name: in.Name, Note: in.Note}, nil
}
func memberApp(t *testing.T) *zip.App {
t.Helper()
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
zip.Get(a, "/v1/t/things/:owner/:name", echoMember)
zip.Patch(a, "/v1/t/things/:owner/:name", echoMember)
return a
}
func do(t *testing.T, a *zip.App, method, path, body string) (int, memberOut) {
t.Helper()
var r io.Reader
if body != "" {
r = strings.NewReader(body)
}
req, err := http.NewRequest(method, path, r)
if err != nil {
t.Fatal(err)
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out memberOut
raw, _ := io.ReadAll(resp.Body)
_ = json.Unmarshal(raw, &out)
return resp.StatusCode, out
}
// A GET member route has no body at all, so the path is the ONLY source of the
// target. Before path binding this decoded to the zero value and the handler
// could not tell which resource was asked for.
func TestPathParamsBindOnGet(t *testing.T) {
code, out := do(t, memberApp(t), "GET", "/v1/t/things/acme/widget", "")
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Owner != "acme" || out.Name != "widget" {
t.Fatalf("got (%q,%q), want (acme,widget)", out.Owner, out.Name)
}
}
// The body still fills every field the path does not name.
func TestBodyFillsNonPathFields(t *testing.T) {
code, out := do(t, memberApp(t), "PATCH", "/v1/t/things/acme/widget", `{"note":"hello"}`)
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Note != "hello" {
t.Fatalf("note = %q, want hello", out.Note)
}
if out.Owner != "acme" || out.Name != "widget" {
t.Fatalf("got (%q,%q), want (acme,widget)", out.Owner, out.Name)
}
}
// THE SECURITY PROPERTY: a body that names a DIFFERENT target than the URL must
// not win. The URL routed the request and the authorizer runs on this same
// decoded value, so if the body could override the path the authorizer would
// approve one resource while the handler wrote another.
func TestPathBeatsBodyOnConflict(t *testing.T) {
code, out := do(t, memberApp(t), "PATCH", "/v1/t/things/acme/widget",
`{"owner":"victim","name":"secret","note":"n"}`)
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Owner != "acme" || out.Name != "widget" {
t.Fatalf("body overrode the URL: got (%q,%q), want (acme,widget)", out.Owner, out.Name)
}
}
// A route with no params must behave exactly as before — the body is the whole
// input. This is what guarantees the change cannot disturb existing collection
// routes.
func TestNoParamsLeavesBodyIntact(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
zip.Post(a, "/v1/t/things", echoMember)
code, out := do(t, a, "POST", "/v1/t/things", `{"owner":"acme","name":"widget","note":"n"}`)
if code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if out.Owner != "acme" || out.Name != "widget" || out.Note != "n" {
t.Fatalf("body binding regressed: %+v", out)
}
}
// The authorizer must see the PATH target, since it runs on the decoded input.
// This is the property IAM's write authorization depends on.
func TestAuthorizerSeesPathTarget(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
var sawOwner, sawName string
a.Authorize(func(_ context.Context, _ zip.Op, in any) (zip.Decision, error) {
if m, ok := in.(*memberIn); ok {
sawOwner, sawName = m.Owner, m.Name
}
return zip.Decision{Effect: zip.Allow}, nil
})
zip.Patch(a, "/v1/t/things/:owner/:name", echoMember)
if code, _ := do(t, a, "PATCH", "/v1/t/things/acme/widget", `{"owner":"victim"}`); code != 200 {
t.Fatalf("status = %d, want 200", code)
}
if sawOwner != "acme" || sawName != "widget" {
t.Fatalf("authorizer saw (%q,%q), want (acme,widget)", sawOwner, sawName)
}
}
// The generated spec must declare every templated segment, or it is invalid
// OpenAPI and every generator downstream rejects it.
func TestOpenAPIDeclaresPathParams(t *testing.T) {
a := memberApp(t)
if err := a.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
req, _ := http.NewRequest("GET", "/.well-known/openapi.json", nil)
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var spec map[string]any
if err := json.Unmarshal(raw, &spec); err != nil {
t.Fatalf("spec is not json: %v", err)
}
paths, _ := spec["paths"].(map[string]any)
item, ok := paths["/v1/t/things/{owner}/{name}"].(map[string]any)
if !ok {
t.Fatalf("templated path missing from spec; got keys %v", keysOf(paths))
}
get, _ := item["get"].(map[string]any)
params, _ := get["parameters"].([]any)
// Path params first, in pattern order, required.
if len(params) < 2 {
t.Fatalf("parameters = %v, want the 2 path params declared", params)
}
for i, want := range []string{"owner", "name"} {
p, _ := params[i].(map[string]any)
if p["name"] != want || p["in"] != "path" || p["required"] != true {
t.Fatalf("parameter %d = %v, want required path param %q", i, p, want)
}
}
// Then the rest of the input, which a GET binds from the query string.
// `note` is not a path segment, so ?note= is the ONLY way to supply it —
// the document must say so or it describes a call nobody can make.
if len(params) != 3 {
t.Fatalf("parameters = %v, want 2 path + 1 query", params)
}
q, _ := params[2].(map[string]any)
if q["name"] != "note" || q["in"] != "query" || q["required"] != false {
t.Fatalf("parameter 2 = %v, want optional query param \"note\"", q)
}
// A field that IS a path segment is declared once, as a path param — never
// duplicated into the query set.
seen := map[string]int{}
for _, raw := range params {
p, _ := raw.(map[string]any)
seen[p["name"].(string)]++
}
for name, n := range seen {
if n != 1 {
t.Errorf("parameter %q declared %d times, want exactly 1", name, n)
}
}
}
// countIn addresses a NUMBERED resource: the path segment binds to an int
// field, and `page` rides the query string of the same request.
type countIn struct {
N int `json:"n"`
Page int32 `json:"page"`
}
func echoCount(_ context.Context, in *countIn) (*countIn, error) { return in, nil }
// A path parameter's type is the type of the field it binds to. Both halves of
// the URL run through one binder (bindURL), so describing one half from the
// input type and hardcoding the other to "string" made the document contradict
// itself about the same value — and every SDK generated from it took a string
// where the handler wanted a number.
func TestOpenAPITypesPathParamsFromTheInput(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
zip.Get(a, "/v1/t/counts/:n", echoCount)
// :missing names no field, so nothing types it and the wire carried text.
zip.Get(a, "/v1/t/other/:missing", echoCount)
params := paramsOf(t, a, "/v1/t/counts/{n}")
if len(params) != 2 {
t.Fatalf("parameters = %v, want the path param and the query param", params)
}
for _, p := range params {
decl, _ := p.(map[string]any)
schema, _ := decl["schema"].(map[string]any)
if schema["type"] != "integer" {
t.Errorf("%v %q schema = %v, want integer", decl["in"], decl["name"], schema)
}
}
missing, _ := paramsOf(t, a, "/v1/t/other/{missing}")[0].(map[string]any)
if s, _ := missing["schema"].(map[string]any); s["type"] != "string" {
t.Errorf("undeclared param schema = %v, want string", s)
}
}
func paramsOf(t *testing.T, a *zip.App, path string) []any {
t.Helper()
spec := a.OpenAPISpec()
paths, _ := spec["paths"].(map[string]map[string]any)
item, ok := paths[path]
if !ok {
t.Fatalf("no path %q in spec", path)
}
get, _ := item["get"].(map[string]any)
params, _ := get["parameters"].([]any)
return params
}
func keysOf(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// A path parameter is percent-encoded on the wire — it has to be: a space, a
// slash or a percent has no other spelling between two slashes. The router
// matches on the raw text, so until this was decoded a handler addressed at
// /v1/t/things/acme/café was handed "caf%C3%A9" and looked up a name nobody
// has. Every client encodes, so before this no client could make such a value
// round-trip.
func TestPathParamsArriveDecoded(t *testing.T) {
for _, c := range []struct{ sent, want string }{
{"plain", "plain"},
{"a%20b", "a b"},
{"a%2Fb", "a/b"}, // a slash inside one segment has only this spelling
{"caf%C3%A9", "café"}, // and non-ASCII has only this one
{"100%25", "100%"},
{"a+b", "a+b"}, // a plus is a plus in a path; only a query reads it as a space
} {
code, out := do(t, memberApp(t), "GET", "/v1/t/things/acme/"+c.sent, "")
if code != 200 {
t.Errorf("%q: status = %d, want 200", c.sent, code)
continue
}
if out.Name != c.want {
t.Errorf("%q arrived as %q, want %q", c.sent, out.Name, c.want)
}
}
}
// The untyped accessor reads the same value the typed binder does. Two
// spellings of one parameter would be two answers to the same question.
func TestParamReadsWhatTheBinderBinds(t *testing.T) {
a := zip.New(zip.Config{AppName: "t", DisableStartupMessage: true})
a.Get("/v1/t/raw/:name", func(c *zip.Ctx) error { return c.String(200, c.Param("name")) })
req, _ := http.NewRequest("GET", "/v1/t/raw/caf%C3%A9", nil)
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) != "café" {
t.Errorf("Param = %q, want %q", body, "café")
}
}