mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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]>
436 lines
16 KiB
Go
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
|
|
}
|