Files
zip/authorize_test.go
T
zeekayandHanzo Dev 9118f9c2e4 WIP: plugin+MCP state still needs to move to the walk
The verb deletion is done and the library builds: Graft, Add, Service, the
Mount VERB and the claims ledger are gone; Load and Mount return definitions;
Plugins/Reload/Unload now find their plugin by walking.

NOT DONE — the same entanglement, one layer over: a plugin's MCP catalogue
(pluginTools/toolOwners/open) is still read off the host that loaded it, but a
plugin is now a definition, so the host reads an empty set and serves no door.
Four mcp_compose/mcp_open tests fail on exactly that. The fix is the same one
that worked for Plugins(): aggregate across App.hosts() instead of reading a
private map, and move the duplicate-tool-name refusal to the walk so two
plugins can actually see each other.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-02 12:34:35 -07:00

133 lines
4.2 KiB
Go

package zip_test
import (
"bytes"
"context"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/zap-proto/zip"
)
type thingIn struct {
Owner string `json:"owner"`
}
type thingOut struct {
OK bool `json:"ok"`
}
// The op-invoke authorizer sees the SAME decoded In the handler will run on, for
// REST and MCP alike, and aborts the op before the handler runs when it refuses.
// This is the seam that makes the value authorized equal the value executed —
// there is no second parse of the body to diverge from.
func TestAuthorizer_GatesDecodedInputAcrossRESTandMCP(t *testing.T) {
app := zip.New(zip.Config{AppName: "authztest", DisableStartupMessage: true})
var handlerRan bool
zip.Post(app, "/v1/things", func(_ context.Context, in *thingIn) (*thingOut, error) {
handlerRan = true
return &thingOut{OK: true}, nil
}, zip.WithOperationID("createThing"))
// The decision keys on the DECODED owner: only "self" passes.
var seen []string
app.Authorize(func(_ context.Context, op zip.Op, in any) error {
ti, ok := in.(*thingIn)
if !ok {
t.Fatalf("authorizer got %T, want *thingIn (the decoded input)", in)
}
seen = append(seen, op.OperationID+"/"+op.Method+":"+ti.Owner)
if ti.Owner != "self" {
return zip.ErrForbidden("forbidden")
}
return nil
})
if err := app.Build(); err != nil {
t.Fatalf("Build: %v", err)
}
post := func(path string, body any) (int, map[string]any) {
b, _ := json.Marshal(body)
req := httptest.NewRequest("POST", path, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Fiber().Test(req)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
_ = resp.Body.Close()
return resp.StatusCode, out
}
// REST allow: an authorized value runs the handler.
handlerRan = false
if code, _ := post("/v1/things", map[string]any{"owner": "self"}); code != 200 {
t.Fatalf("REST allow status = %d, want 200", code)
}
if !handlerRan {
t.Fatal("handler must run when the authorizer allows (REST)")
}
// REST deny: a refused value is 403 and the handler never runs.
handlerRan = false
if code, _ := post("/v1/things", map[string]any{"owner": "attacker"}); code != 403 {
t.Fatalf("REST deny status = %d, want 403", code)
}
if handlerRan {
t.Fatal("handler must NOT run when the authorizer refuses (REST)")
}
// MCP deny: the SAME hook gates tools/call on the decoded arguments — the
// handler never runs, and the result is an MCP isError (not a transport error).
handlerRan = false
_, out := post("/mcp", map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": "createThing", "arguments": map[string]any{"owner": "attacker"}},
})
if handlerRan {
t.Fatal("handler must NOT run when the authorizer refuses (MCP)")
}
if result, _ := out["result"].(map[string]any); result == nil || result["isError"] != true {
t.Fatalf("MCP deny should be an isError result, got %v", out)
}
// MCP allow: authorized arguments run the handler over MCP too.
handlerRan = false
post("/mcp", map[string]any{
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": map[string]any{"name": "createThing", "arguments": map[string]any{"owner": "self"}},
})
if !handlerRan {
t.Fatal("handler must run when the authorizer allows (MCP)")
}
// The authorizer was consulted on the decoded owner for both transports.
if len(seen) != 4 {
t.Fatalf("authorizer calls = %v, want 4 (REST allow/deny + MCP deny/allow)", seen)
}
}
// A nil authorizer leaves the op open — the framework default is unchanged for
// apps that install none.
func TestAuthorizer_NilIsOpen(t *testing.T) {
app := zip.New(zip.Config{DisableStartupMessage: true})
var ran bool
zip.Post(app, "/v1/open", func(_ context.Context, _ *thingIn) (*thingOut, error) {
ran = true
return &thingOut{OK: true}, nil
})
b, _ := json.Marshal(map[string]any{"owner": "anyone"})
req := httptest.NewRequest("POST", "/v1/open", bytes.NewReader(b))
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 != 200 || !ran {
t.Fatalf("nil authorizer must leave the op open: status=%d ran=%v", resp.StatusCode, ran)
}
}