Files
zip/link_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

236 lines
7.9 KiB
Go

// Copyright 2026 Hanzo AI, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
package zip
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// linkIn takes nothing; linkOut is the smallest shape that makes the op typed,
// which is what turns an app's description on.
type linkIn struct{}
type linkOut struct {
Name string `json:"name"`
}
// linked builds an app that serves a document — one typed op is what turns the
// description on — and returns its response to one GET.
func linked(t *testing.T, path string) *http.Response {
t.Helper()
a := quiet("linked")
a.Get("/thing", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
return &linkOut{Name: "thing"}, nil
})
a.installOpenAPIRoutes()
if err := a.Build(); err != nil {
t.Fatalf("build: %v", err)
}
resp, err := a.Fiber().Test(httptest.NewRequest("GET", path, nil))
if err != nil {
t.Fatalf("GET %s: %v", path, err)
}
return resp
}
// rel returns the target of one link relation, or "" when the answer carries none.
func rel(resp *http.Response, name string) string {
for _, l := range resp.Header.Values("Link") {
if !strings.Contains(l, `rel="`+name+`"`) {
continue
}
if i, j := strings.Index(l, "<"), strings.Index(l, ">"); i == 0 && j > 0 {
return l[1:j]
}
}
return ""
}
// THE PROPERTY: a link never names an address this app does not serve.
//
// It is the whole reason the header is derived from the same map that installs
// the route rather than written beside it. A description advertised at an
// address that 404s is worse than none — a generic client follows it, gets
// nothing, and reports the API as broken rather than as undescribed.
func TestLink_NamesOnlyAddressesThisAppServes(t *testing.T) {
a := quiet("served")
a.Get("/thing", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
return &linkOut{Name: "thing"}, nil
})
a.installOpenAPIRoutes()
if err := a.Build(); err != nil {
t.Fatalf("build: %v", err)
}
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/thing", nil))
if err != nil {
t.Fatalf("GET /thing: %v", err)
}
targets := map[string]string{
relDesc: rel(resp, relDesc),
relDoc: rel(resp, relDoc),
}
for name, at := range targets {
if at == "" {
t.Fatalf("no %s link on the answer", name)
}
// FOLLOW it, which is the only check that can fail for the right reason.
got, err := a.Fiber().Test(httptest.NewRequest("GET", at, nil))
if err != nil {
t.Fatalf("following %s -> %s: %v", name, at, err)
}
_, _ = io.Copy(io.Discard, got.Body)
_ = got.Body.Close()
if got.StatusCode != 200 {
t.Errorf("%s names %s, which answers %d — a link must not advertise an "+
"address this app does not serve", name, at, got.StatusCode)
}
}
}
// self is the address the caller REACHED, which is the one thing a client cannot
// reconstruct: it may have arrived through a proxy, an alias or a redirect.
func TestLink_SelfIsTheAddressReached(t *testing.T) {
resp := linked(t, "/thing")
if got := rel(resp, relSelf); got != "/thing" {
t.Errorf("self = %q, want /thing", got)
}
}
// AN APP WITH NO DESCRIPTION STILL NAMES ITSELF. Disabling the document says
// nothing about whether an answer has an address, so self stays and only the
// relations that would 404 are withheld.
func TestLink_AnAppWithNoDocumentStillNamesItself(t *testing.T) {
a := New(Config{AppName: "silent", DisableStartupMessage: true,
OpenAPI: OpenAPIConfig{Disabled: true}})
a.Get("/thing", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
return &linkOut{Name: "thing"}, nil
})
a.installOpenAPIRoutes() // no-op when Disabled
if err := a.Build(); err != nil {
t.Fatalf("build: %v", err)
}
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/thing", nil))
if err != nil {
t.Fatalf("GET /thing: %v", err)
}
got := resp.Header.Values("Link")
if len(got) != 1 || !strings.Contains(got[0], `rel="self"`) {
t.Errorf("want self alone, got %v", got)
}
for _, l := range got {
if strings.Contains(l, "service-desc") || strings.Contains(l, "service-doc") {
t.Errorf("advertised a document it does not serve: %s", l)
}
}
}
// A ROUTE IS NOT A TYPED OP, AND AN ANSWER IS STILL AN ANSWER. installOpenAPIRoutes
// returns early for an app with an empty registry, and self used to ride that
// same condition — so an app serving plain routes, which most of ours do,
// named nothing. This is the case that was silently uncovered.
func TestLink_PlainRoutesAreNamedToo(t *testing.T) {
a := New(Config{AppName: "plain", DisableStartupMessage: true})
a.Raw("GET", "/plain", func(c *Ctx) error { return c.String(200, "ok") })
a.installOpenAPIRoutes() // returns early: nothing typed to describe
if err := a.Build(); err != nil {
t.Fatalf("build: %v", err)
}
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/plain", nil))
if err != nil {
t.Fatalf("GET /plain: %v", err)
}
got := resp.Header.Values("Link")
if len(got) != 1 || !strings.Contains(got[0], `</plain>; rel="self"`) {
t.Errorf("a plain route must name its own address, got %v", got)
}
}
// Link is a LIST header (RFC 8288 §3), so a handler's own links SURVIVE. A page
// naming next and prev keeps them and gets the service relations beside them —
// setting rather than adding would silently drop the handler's own paging.
func TestLink_AHandlersOwnLinksSurvive(t *testing.T) {
a := quiet("paged")
// BEFORE the route: zip composes middleware into the routes written after it,
// so one installed below its leaves never wraps them.
a.Use(H(func(c *Ctx) error {
c.fc.Response().Header.Add("Link", `</page?after=2>; rel="next"`)
return c.Continue()
}))
a.Get("/page", func(ctx context.Context, _ *linkIn) (*linkOut, error) {
return &linkOut{Name: "page"}, nil
})
a.installOpenAPIRoutes()
if err := a.Build(); err != nil {
t.Fatalf("build: %v", err)
}
resp, err := a.Fiber().Test(httptest.NewRequest("GET", "/page", nil))
if err != nil {
t.Fatalf("GET /page: %v", err)
}
if got := rel(resp, "next"); got != "/page?after=2" {
t.Errorf("the handler's own next link = %q — the service relations replaced "+
"it instead of joining it", got)
}
if rel(resp, relDesc) == "" {
t.Error("the service description link is missing beside the handler's own")
}
}
// AN ANSWER THAT ALREADY NAMES ITSELF IS NOT NAMED AGAIN.
//
// This is the shape of a proxied answer: the far end served it and wrote its own
// links into this very response, so by the time the near side's middleware runs
// they are already here. Adding a second copy is what put three self links on one
// production answer.
func TestSelfLinkIsNotWrittenTwice(t *testing.T) {
a := New(Config{AppName: "hop", DisableStartupMessage: true})
// A handler standing in for the far end of a hop.
a.Raw("GET", "/v1/relayed", func(c *Ctx) error {
c.Fiber().Response().Header.Add("Link", `</v1/relayed>; rel="self"`)
c.Fiber().Response().Header.Add("Link", `</.well-known/openapi.json>; rel="service-desc"`)
return c.JSON(200, map[string]bool{"ok": true})
})
a.Get("/v1/own", func(_ context.Context, _ *struct{}) (*struct {
OK bool `json:"ok"`
}, error) {
return &struct {
OK bool `json:"ok"`
}{true}, nil
})
if err := a.Build(); err != nil {
t.Fatal(err)
}
count := func(path, rel string) int {
res, err := a.Fiber().Test(httptest.NewRequest("GET", path, nil))
if err != nil {
t.Fatal(err)
}
defer func() { _ = res.Body.Close() }()
n := 0
for _, v := range res.Header.Values("Link") {
if strings.Contains(v, `rel="`+rel+`"`) {
n++
}
}
return n
}
if n := count("/v1/relayed", "self"); n != 1 {
t.Errorf("a relayed answer carries %d self links, want 1", n)
}
if n := count("/v1/relayed", "service-desc"); n != 1 {
t.Errorf("a relayed answer carries %d service-desc links, want 1", n)
}
// And an answer this app served itself still gets them, or the guard has
// simply turned the header off.
if n := count("/v1/own", "self"); n != 1 {
t.Errorf("an answer this app served carries %d self links, want 1", n)
}
}