Files
zip/optarget_test.go
zeekayandHanzo Dev 9d386ae349 typed: OpScope is exported, because a decorating Router has to implement it
v1.18.0 sealed OpTarget with an unexported method, reasoning that nothing outside
zip should claim to be somewhere an op lives. That reasoning was wrong, and
building hanzoai/cloud against it is what said so: hanzoai/commerce's
middleware.Mint decorates a zip.Router — it prepends a platform-only gate to
every route registered through it, so that money-mint routes cannot be declared
without their gate. A sealed method made Mint uncompilable, with no fix available
to it from outside this package.

Worse than uncompilable is the version that compiles: a decorator that embeds the
wrapped Router and inherits its OpScope registers a typed money-mint op with NO
GATE. The decorator's entire purpose, silently skipped, on the routes that move
money.

So the method is exported and returns a value that says what it is —
OpScope{App, Prefix, Middleware} — and its doc says what a decorator owes: fold
your middleware into s.Middleware or the op you register is ungated. A struct
rather than three returns so a later field does not break implementors.

TestOpTarget_AnOutsideDecoratorCanCarryOps is the shape, written as an outside
package would write it: embed the Router, override OpScope, assert the gate
reaches a typed op. That test is what a sealed interface could not have.

My compatibility check for v1.18.0 grepped the local source trees and not the
module cache, which is why a published implementor got through. Building the
consumer is the check that finds this; the grep is not.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 17:30:41 -07:00

215 lines
6.9 KiB
Go

package zip_test
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// groupIn addresses one thing under a group.
type groupIn struct {
ID string `json:"id"`
Note string `json:"note"`
}
type groupOut struct {
ID string `json:"id"`
Note string `json:"note"`
Seen string `json:"seen"`
}
func echoGroup(_ context.Context, in *groupIn) (*groupOut, error) {
return &groupOut{ID: in.ID, Note: in.Note}, nil
}
func call2(t *testing.T, a *zip.App, method, path, body string) (int, string) {
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()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(raw)
}
// A typed op declared on a Group carries the group's prefix — in the ROUTE it
// answers on and in the identity every projection reads. Before this, zip.Get
// took the *App only, so an app built on groups had to spell its prefix out per
// route to have typed ops at all: friction that made the untyped handler, which
// does inherit the prefix, the path of least resistance.
func TestOpTarget_GroupPrefixIsPartOfTheOp(t *testing.T) {
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
v1 := a.Group("/v1")
things := v1.Group("/things")
zip.Get(things, "/:id", echoGroup)
zip.Post(v1, "/direct", echoGroup)
// The route answers under the composed prefix.
if code, body := call2(t, a, "GET", "/v1/things/abc?note=hi", ""); code != 200 ||
!strings.Contains(body, `"id":"abc"`) || !strings.Contains(body, `"note":"hi"`) {
t.Fatalf("GET /v1/things/abc = %d %s", code, body)
}
if code, _ := call2(t, a, "GET", "/things/abc", ""); code != 404 {
t.Errorf("GET /things/abc = %d, want 404 — the prefix is part of the route", code)
}
// And the identity every projection reads is the whole path.
paths := map[string]bool{}
for _, c := range a.Commands() {
paths[c.Path] = true
}
for _, want := range []string{"/v1/things/:id", "/v1/direct"} {
if !paths[want] {
t.Errorf("no op at %q; ops are %v", want, keysOf(anyMap(paths)))
}
}
spec, _ := a.OpenAPISpec()["paths"].(map[string]map[string]any)
if _, ok := spec["/v1/things/{id}"]; !ok {
t.Errorf("document has no /v1/things/{id}; paths = %v", keysOf(anyMap2(spec)))
}
}
// A typed op and an untyped one declared on the SAME group land on the same
// prefix. zip composes the typed op's path itself, so this is the test that says
// its composition is the router's composition and not a second rule beside it.
func TestOpTarget_TypedAndUntypedComposeAlike(t *testing.T) {
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
g := a.Group("/v1/mix")
g.Get("/untyped", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"kind": "untyped"}) })
zip.Get(a.Group("/v1/mix"), "/typed", echoGroup)
if code, body := call2(t, a, "GET", "/v1/mix/untyped", ""); code != 200 || !strings.Contains(body, "untyped") {
t.Errorf("untyped leaf = %d %s", code, body)
}
if code, _ := call2(t, a, "GET", "/v1/mix/typed", ""); code != 200 {
t.Errorf("typed leaf = %d, want 200 at the same prefix", code)
}
}
// With() middleware composes around a typed op too. Dropping it silently would
// be a hole rather than an inconvenience: With is where the gates go.
func TestOpTarget_WithWrapsATypedOp(t *testing.T) {
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
gate := func(next zip.Handler) zip.Handler {
return func(c *zip.Ctx) error {
if c.Header("X-Pass") != "yes" {
return zip.ErrForbidden("gated")
}
c.SetHeader("X-Gate", "ran")
return next(c)
}
}
zip.Get(a.With(gate), "/v1/gated/:id", echoGroup)
if code, body := call2(t, a, "GET", "/v1/gated/x", ""); code != 403 {
t.Fatalf("ungated request = %d %s, want 403 — the middleware did not run", code, body)
}
req, _ := http.NewRequest("GET", "/v1/gated/x", nil)
req.Header.Set("X-Pass", "yes")
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("gated-through request = %d, want 200", resp.StatusCode)
}
if resp.Header.Get("X-Gate") != "ran" {
t.Errorf("X-Gate = %q, want the middleware's mark", resp.Header.Get("X-Gate"))
}
// It is still one op, with the whole path.
if cmds := a.Commands(); len(cmds) != 1 || cmds[0].Path != "/v1/gated/:id" {
t.Errorf("commands = %+v, want one op at /v1/gated/:id", cmds)
}
}
func anyMap(m map[string]bool) map[string]any {
out := map[string]any{}
for k := range m {
out[k] = nil
}
return out
}
func anyMap2(m map[string]map[string]any) map[string]any {
out := map[string]any{}
for k := range m {
out[k] = nil
}
return out
}
// gateRouter is the shape a decorating Router outside this package has to take:
// it wraps another Router and overrides OpScope so the gate it installs on every
// untyped route also reaches a typed op declared through it. Getting this wrong
// is not an inconvenience — it registers an UNGATED op on a router whose whole
// purpose is the gate. hanzoai/commerce's Mint is the real one.
type gateRouter struct {
zip.Router // embedded: everything this decorator does not override
gate zip.Middleware
}
func (g gateRouter) OpScope() zip.OpScope {
s := g.Router.OpScope()
if s.Middleware == nil {
s.Middleware = g.gate
} else {
s.Middleware = zip.Chain(s.Middleware, g.gate)
}
return s
}
// A Router implemented OUTSIDE zip can carry typed ops, and its middleware
// reaches them. OpTarget is exported for exactly this: sealing it would have
// made every decorating router in the fleet unable to compile, and the ones that
// could would have silently dropped their gate.
func TestOpTarget_AnOutsideDecoratorCanCarryOps(t *testing.T) {
a := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
gate := func(next zip.Handler) zip.Handler {
return func(c *zip.Ctx) error {
if c.Header("X-Pass") != "yes" {
return zip.ErrForbidden("gated")
}
return next(c)
}
}
mint := gateRouter{Router: a.Group("/v1/mint"), gate: gate}
zip.Post(mint, "/deposit", echoGroup)
if cmds := a.Commands(); len(cmds) != 1 || cmds[0].Path != "/v1/mint/deposit" {
t.Fatalf("commands = %+v, want one op at /v1/mint/deposit", cmds)
}
if code, _ := call2(t, a, "POST", "/v1/mint/deposit", `{"id":"x"}`); code != 403 {
t.Fatalf("ungated = %d, want 403 — the decorator's gate did not reach the op", code)
}
req, _ := http.NewRequest("POST", "/v1/mint/deposit", strings.NewReader(`{"id":"x"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Pass", "yes")
resp, err := a.Fiber().Test(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("gated-through = %d, want 200", resp.StatusCode)
}
}