Files
zip/decision_test.go
zeekayandHanzo Dev 5f71d2496b The router is three nouns, and every one of them is concrete
App, Group, Operation. A group is a prefix and what holds under it, an operation
is one typed route declared there, and the verb methods are generic methods —
which Go 1.27 allows on a concrete type and still refuses on an interface. So In
and Out come from the handler and the caller writes no type arguments:

	users := app.Group("/v1/users")
	users.Post("/", api.Create)

	mint := users.Group("/mint").With(middleware.Mint(gate))
	mint.Post("/deposit", api.Deposit)

A Group is a CLOSED HANDLE: two unexported fields, and no method that hands back
the app, the parent or the fiber underneath. A subsystem given the group at its
own prefix cannot reach above it, so confinement is a property of the type
instead of a rule something has to police — which is what a host needs in order
to hand out a bounded surface and know what comes back.

That is bought by giving up the router interface, deliberately. Router and
OpTarget are gone, and with them the ability to implement a route table outside
this package: a decorator is no longer a router, a gate is middleware on a group,
and a bound is a path. The five package-level verb functions go the same way —
a registration is a method on the thing it registers into.

Raw carries what is not a typed operation: a stream, an upgrade, a probe, a
foreign subtree. It names its method, and it is the longer spelling because
opting out of the typed model is the exception and should read like one.



Co-authored-by: Hanzo Dev <[email protected]>
2026-09-17 01:39:17 -07:00

436 lines
16 KiB
Go

package zip_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http/httptest"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// actOut is the reply of the op the parity table drives. Package-scoped because
// every projection asserts on it.
type actOut struct {
OK bool `json:"ok"`
}
// The three-valued Decision renders the same outcome on EVERY projection.
//
// Allow runs the handler. Deny refuses without running it, carrying the clause
// and the reason. Approve holds it without running it, and what comes back says
// so in the one shape [zip.Approval] defines. One seam decides — the run()
// contract both op.invoke and op.direct funnel through — and the projections
// only render, so a table over the three effects is a proof that all six agree.
//
// The two Go projections earn their place because they are where a held op is
// easiest to lose: [zip.Here] would report it as the caller's mistake about a
// type, and the call plane would send it under a 200 that [zip.Call] decodes
// into Out — a held op arriving as a finished one carrying a value the handler
// never produced.
func TestDecision_ProjectionParity(t *testing.T) {
cases := []struct {
name string
owner string // drives the decision below
restCode int // the status REST renders for this effect
ran bool // whether the handler must have run
mcp func(*testing.T, map[string]any)
}{
{
name: "allow", owner: "self", restCode: 200, ran: true,
mcp: func(t *testing.T, result map[string]any) {
if result["isError"] == true {
t.Fatalf("allow must not be an MCP error: %v", result)
}
if got := mcpText(t, result); !strings.Contains(got, `"ok":true`) {
t.Fatalf("allow MCP content = %q, want the handler's output", got)
}
},
},
{
name: "deny", owner: "attacker", restCode: 403, ran: false,
mcp: func(t *testing.T, result map[string]any) {
if result["isError"] != true {
t.Fatalf("deny must be an MCP isError: %v", result)
}
if got := mcpText(t, result); !strings.Contains(got, "not the owner") {
t.Fatalf("deny MCP content = %q, want the reason", got)
}
},
},
{
name: "approve", owner: "review", restCode: 202, ran: false,
mcp: func(t *testing.T, result map[string]any) {
if result["isError"] == true {
t.Fatalf("a held op is not a failure and must not be an MCP error: %v", result)
}
if got := mcpText(t, result); !strings.Contains(got, `"status":"held"`) {
t.Fatalf("approve MCP content = %q, want a held body", got)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var ran bool
app := zip.New(zip.Config{AppName: "parity", DisableStartupMessage: true})
app.Post("/v1/act", func(_ context.Context, _ *thingIn) (*actOut, error) {
ran = true
return &actOut{OK: true}, nil
}, zip.WithOperationID("act"))
// ONE rule, three answers, keyed on the decoded owner.
app.Authorize(func(_ context.Context, _ zip.Op, in any) (zip.Decision, error) {
switch in.(*thingIn).Owner {
case "self":
return zip.Decision{Effect: zip.Allow}, nil
case "review":
return zip.Decision{Effect: zip.Approve, Clause: "kit", Reason: "needs sign-off"}, nil
default:
return zip.Decision{Effect: zip.Deny, Clause: "owner", Reason: "not the owner"}, nil
}
})
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
// REST.
ran = false
body, _ := json.Marshal(map[string]any{"owner": tc.owner})
req := httptest.NewRequest("POST", "/v1/act", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("REST: %v", err)
}
var restBody map[string]any
_ = json.NewDecoder(resp.Body).Decode(&restBody)
_ = resp.Body.Close()
if resp.StatusCode != tc.restCode {
t.Fatalf("REST %s status = %d, want %d", tc.name, resp.StatusCode, tc.restCode)
}
if ran != tc.ran {
t.Fatalf("REST %s handler ran = %v, want %v", tc.name, ran, tc.ran)
}
switch tc.name {
case "deny":
if restBody["status"] != float64(403) || restBody["code"] != "owner" || restBody["detail"] != "not the owner" {
t.Fatalf("REST deny body must carry the clause and the reason: %v", restBody)
}
case "approve":
if restBody["status"] != zip.Held || restBody["clause"] != "kit" || restBody["reason"] != "needs sign-off" {
t.Fatalf("REST approve body must be the approval: %v", restBody)
}
}
// MCP — same rule, same three outcomes, no route underneath.
ran = false
out := mcpCall(t, app, "act", map[string]any{"owner": tc.owner})
result, _ := out["result"].(map[string]any)
if result == nil {
t.Fatalf("MCP %s: no result in %v", tc.name, out)
}
if ran != tc.ran {
t.Fatalf("MCP %s handler ran = %v, want %v", tc.name, ran, tc.ran)
}
tc.mcp(t, result)
// In-process — no wire at all.
ran = false
local, err := zip.Here[thingIn, actOut](context.Background(), app, "act", &thingIn{Owner: tc.owner})
if ran != tc.ran {
t.Fatalf("Here %s handler ran = %v, want %v", tc.name, ran, tc.ran)
}
inGo(t, tc.name, local, err)
// The graph, which has only two lanes — data and errors — so a held
// op is reported beside the field rather than projected as a value.
ran = false
g := app.GraphQL(context.Background(), zip.GraphRequest{
Query: `mutation ($o: String) { act(owner: $o) { ok } }`,
Variables: map[string]any{"o": tc.owner},
})
if ran != tc.ran {
t.Fatalf("graph %s handler ran = %v, want %v", tc.name, ran, tc.ran)
}
field, _ := g.Data.(map[string]any)["act"].(map[string]any)
switch tc.name {
case "allow":
if len(g.Errors) != 0 || field["ok"] != true {
t.Fatalf("graph allow = %+v errs=%v, want the handler's field", field, g.Errors)
}
case "deny":
if field != nil || !graphSays(g, "not the owner") {
t.Fatalf("graph deny = %+v errs=%v, want the reason and no data", field, g.Errors)
}
case "approve":
if field != nil || !graphSays(g, "held for approval: kit") {
t.Fatalf("graph approve = %+v errs=%v, want the approval and no data", field, g.Errors)
}
}
// The CLI, run in-process through the same seam. It prints what came
// back, so a held op prints the approval and not a fabricated reply.
ran = false
cmd := commandFor(t, app, "act")
var printed bytes.Buffer
cli := app.CLI()
cli.Out = &printed
err = cli.Run(context.Background(), []string{cmd.Service, cmd.Name, "--owner", tc.owner})
if ran != tc.ran {
t.Fatalf("CLI %s handler ran = %v, want %v", tc.name, ran, tc.ran)
}
switch tc.name {
case "allow":
if err != nil || !strings.Contains(printed.String(), `"ok": true`) {
t.Fatalf("CLI allow printed %q err=%v, want the handler's output", printed.String(), err)
}
case "deny":
if err == nil || !strings.Contains(err.Error(), "not the owner") {
t.Fatalf("CLI deny err = %v, want the reason", err)
}
case "approve":
if err != nil {
t.Fatalf("CLI approve err = %v — a held op is not a failure", err)
}
if !strings.Contains(printed.String(), `"status": "held"`) ||
!strings.Contains(printed.String(), `"clause": "kit"`) {
t.Fatalf("CLI approve printed %q, want the approval", printed.String())
}
}
// The call plane — the same op over a real socket, addressed by name.
t.Setenv(zip.RuntimeDirEnv, sockDir(t))
sock := zip.SocketPath("parity")
go func() { _ = app.Listen(zip.Addr(sock)) }()
t.Cleanup(func() { _ = app.Shutdown() })
waitFor(t, sock)
conn, derr := zip.DialApp("parity")
if derr != nil {
t.Fatalf("dial: %v", derr)
}
defer func() { _ = conn.Close() }()
ran = false
remote, err := zip.Call[thingIn, actOut](context.Background(), conn, "act", &thingIn{Owner: tc.owner})
if ran != tc.ran {
t.Fatalf("Call %s handler ran = %v, want %v", tc.name, ran, tc.ran)
}
inGo(t, tc.name, remote, err)
})
}
}
// inGo asserts the (value, error) pair a Go caller gets — in-process or over the
// call plane — carries the effect and nothing else. A held op has NO value: the
// whole point is that a caller cannot read one out of it.
func inGo(t *testing.T, effect string, out *actOut, err error) {
t.Helper()
a, held := zip.HeldOf(err)
switch effect {
case "allow":
if err != nil || out == nil || !out.OK {
t.Fatalf("allow: got (%+v, %v), want the handler's value", out, err)
}
case "deny":
if held {
t.Fatalf("deny came back as a held op: %+v", a)
}
var he *zip.HTTPError
if !errors.As(err, &he) || he.Status != 403 || he.Code != "owner" || he.Msg != "not the owner" {
t.Fatalf("deny: err = %v, want a 403 carrying the clause and the reason", err)
}
if out != nil {
t.Fatalf("deny returned a value: %+v", out)
}
case "approve":
if !held {
t.Fatalf("approve: err = %v (%T), want a *zip.Approval readable by zip.HeldOf", err, err)
}
if out != nil {
t.Fatalf("approve returned the value %+v — a held op has no reply, and reading one reads a fabrication", out)
}
if a.Status != zip.Held || a.Clause != "kit" || a.Reason != "needs sign-off" {
t.Fatalf("approval = %+v, want {held, kit, needs sign-off}", a)
}
}
}
// A rule that fails is not a rule that refuses. An authorizer whose authority is
// unreachable answers on the error lane, and that error is the response — a 503
// rather than the 403 a Deny renders, because "I could not decide" and "I
// decided no" are different answers and a client retries only one of them.
func TestDecision_AFailedCheckIsNotARefusal(t *testing.T) {
app := zip.New(zip.Config{AppName: "broken", DisableStartupMessage: true})
app.Post("/v1/act", func(_ context.Context, _ *thingIn) (*actOut, error) {
return &actOut{OK: true}, nil
}, zip.WithOperationID("act"))
app.Authorize(func(context.Context, zip.Op, any) (zip.Decision, error) {
return zip.Decision{}, zip.Errorf(503, "the authority is unreachable")
})
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
req := httptest.NewRequest("POST", "/v1/act", strings.NewReader(`{"owner":"self"}`))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != 503 {
t.Fatalf("status = %d, want 503 — a check that failed is not a refusal", resp.StatusCode)
}
}
// A gated service publishes the held body beside every op's success response, so
// a generated client reads the pair instead of mistaking a held op for a
// finished one. An op under no rule never holds, so its document says nothing
// about it.
func TestDecision_TheDocumentPublishesTheHeldBody(t *testing.T) {
responses := func(gated bool) map[string]any {
app := zip.New(zip.Config{AppName: "doc", DisableStartupMessage: true})
app.Post("/v1/act", func(_ context.Context, _ *thingIn) (*thingOut, error) {
return &thingOut{OK: true}, nil
}, zip.WithOperationID("act"))
if gated {
app.Authorize(func(context.Context, zip.Op, any) (zip.Decision, error) {
return zip.Decision{Effect: zip.Allow}, nil
})
}
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
paths := app.OpenAPISpec()["paths"].(map[string]map[string]any)
return paths["/v1/act"]["post"].(map[string]any)["responses"].(map[string]any)
}
held, ok := responses(true)["202"].(map[string]any)
if !ok {
t.Fatal("a gated op must publish the held response")
}
schema, _ := held["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
if schema == nil {
t.Fatalf("the held response must publish a schema: %v", held)
}
if _, ok := responses(false)["202"]; ok {
t.Fatal("an op under no rule must not publish the held response")
}
}
// A HOST'S RULE REACHES A COMPOSED CHILD, so the host's document must say the
// child's ops can hold. The rule is settled at build (see adopt), which is after
// the child registered its ops and before the document renders — so the document
// asks the same question the invoke seam asks, and cannot publish a contract the
// seam does not keep.
func TestDecision_TheDocumentFollowsTheAdoptedRule(t *testing.T) {
child := zip.New(zip.Config{AppName: "child", DisableStartupMessage: true})
child.Post("/v1/child/act", func(_ context.Context, _ *thingIn) (*thingOut, error) {
return &thingOut{OK: true}, nil
}, zip.WithOperationID("childAct"))
host := zip.New(zip.Config{AppName: "host", DisableStartupMessage: true})
host.Authorize(func(context.Context, zip.Op, any) (zip.Decision, error) {
return zip.Decision{Effect: zip.Approve, Clause: "review", Reason: "sign it off"}, nil
})
host.Use(child)
if err := host.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
paths := host.OpenAPISpec()["paths"].(map[string]map[string]any)
resp := paths["/v1/child/act"]["post"].(map[string]any)["responses"].(map[string]any)
if _, ok := resp["202"]; !ok {
t.Fatalf("the host's rule governs this op, so its document must publish the held body: %v", resp)
}
// And the seam keeps it.
req := httptest.NewRequest("POST", "/v1/child/act", strings.NewReader(`{"owner":"x"}`))
req.Header.Set("Content-Type", "application/json")
got, err := host.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
_ = got.Body.Close()
if got.StatusCode != 202 {
t.Fatalf("status = %d, want the 202 the document publishes", got.StatusCode)
}
}
// An op that declares 202 as its OWN success keeps that meaning. The held body
// is added beside an op's responses, never over one it declared — two facts
// about one code cannot both be published, and the op's own is the older claim.
func TestDecision_ADeclared202IsNotOverwritten(t *testing.T) {
app := zip.New(zip.Config{AppName: "queue", DisableStartupMessage: true})
app.Post("/v1/queue", func(_ context.Context, _ *thingIn) (*thingOut, error) {
return &thingOut{OK: true}, nil
}, zip.WithStatus(202), zip.WithOperationID("queue"))
app.Authorize(func(context.Context, zip.Op, any) (zip.Decision, error) {
return zip.Decision{Effect: zip.Allow}, nil
})
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
paths := app.OpenAPISpec()["paths"].(map[string]map[string]any)
resp := paths["/v1/queue"]["post"].(map[string]any)["responses"].(map[string]any)
entry, _ := resp["202"].(map[string]any)
if entry["description"] == "held for approval" {
t.Fatalf("the op's own 202 was overwritten by the held body: %v", entry)
}
}
// commandFor is the CLI command that runs the op named id.
func commandFor(t *testing.T, app *zip.App, id string) zip.Command {
t.Helper()
for _, c := range app.Commands() {
if c.OperationID == id {
return c
}
}
t.Fatalf("no CLI command for op %q", id)
return zip.Command{}
}
// graphSays reports whether any graph error carries want.
func graphSays(r zip.GraphResponse, want string) bool {
for _, e := range r.Errors {
if strings.Contains(e.Message, want) {
return true
}
}
return false
}
// mcpCall runs one tools/call over the app's /mcp route and returns the decoded
// JSON-RPC envelope.
func mcpCall(t *testing.T, app *zip.App, tool string, args map[string]any) map[string]any {
t.Helper()
b, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": tool, "arguments": args},
})
req := httptest.NewRequest("POST", "/mcp", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("MCP: %v", err)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
return out
}
// mcpText pulls the first text content out of one MCP tool result.
func mcpText(t *testing.T, result map[string]any) string {
t.Helper()
content, _ := result["content"].([]any)
if len(content) == 0 {
t.Fatalf("no MCP content in %v", result)
}
first, _ := content[0].(map[string]any)
text, _ := first["text"].(string)
return text
}