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]>
306 lines
12 KiB
Go
306 lines
12 KiB
Go
package zip_test
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/valyala/fasthttp"
|
|
"github.com/zap-proto/http"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// TestListen_ZAP proves a bare address (DefaultScheme) serves over the real ZAP
|
|
// transport: a route on the App answers a request over ZAP with the same
|
|
// handler/JSON path as HTTP.
|
|
func TestListen_ZAP(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "zaptest", DisableStartupMessage: true})
|
|
app.Raw("GET", "/v1/health", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{"status": "ok", "transport": "zap"})
|
|
})
|
|
|
|
addr := freeAddr(t)
|
|
go func() { _ = app.Listen(addr) }() // bare addr = ZAP (DefaultScheme)
|
|
defer func() { _ = app.Shutdown() }()
|
|
|
|
// Wait for the ZAP listener to accept (bounded).
|
|
waitReachable(t, addr)
|
|
|
|
req := fasthttp.AcquireRequest()
|
|
resp := fasthttp.AcquireResponse()
|
|
defer fasthttp.ReleaseRequest(req)
|
|
defer fasthttp.ReleaseResponse(resp)
|
|
req.SetRequestURI("/v1/health")
|
|
req.Header.SetMethod(fasthttp.MethodGet)
|
|
|
|
tr := http.Dial("tcp", addr)
|
|
defer tr.CloseIdleConnections()
|
|
if err := tr.Do(req, resp); err != nil {
|
|
t.Fatalf("ZAP round-trip failed: %v", err)
|
|
}
|
|
|
|
if resp.StatusCode() != 200 {
|
|
t.Fatalf("status = %d, want 200", resp.StatusCode())
|
|
}
|
|
body := string(resp.Body())
|
|
if !strings.Contains(body, `"status":"ok"`) || !strings.Contains(body, `"transport":"zap"`) {
|
|
t.Fatalf("body over ZAP = %q, want the handler's JSON", body)
|
|
}
|
|
}
|
|
|
|
// TestListen_DualTransport proves the decomplected design: ONE Listen call with
|
|
// two addresses serves the SAME handler over BOTH transports (ZAP + HTTP), the
|
|
// scheme selecting each. This is the headline of "one verb, transport is a value".
|
|
func TestListen_DualTransport(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "dual", DisableStartupMessage: true})
|
|
app.Raw("GET", "/v1/health", func(c *zip.Ctx) error {
|
|
return c.JSON(200, map[string]string{"status": "ok"})
|
|
})
|
|
|
|
zapAddr := freeAddr(t)
|
|
httpAddr := freeAddr(t)
|
|
go func() { _ = app.Listen(zapAddr, "http://"+httpAddr) }() // ONE call, both transports
|
|
defer func() { _ = app.Shutdown() }()
|
|
waitReachable(t, zapAddr)
|
|
|
|
// ZAP side.
|
|
ztr := http.Dial("tcp", zapAddr)
|
|
defer ztr.CloseIdleConnections()
|
|
zreq, zresp := fasthttp.AcquireRequest(), fasthttp.AcquireResponse()
|
|
defer fasthttp.ReleaseRequest(zreq)
|
|
defer fasthttp.ReleaseResponse(zresp)
|
|
zreq.SetRequestURI("/v1/health")
|
|
if err := ztr.Do(zreq, zresp); err != nil || zresp.StatusCode() != 200 {
|
|
t.Fatalf("ZAP transport: err=%v status=%d", err, zresp.StatusCode())
|
|
}
|
|
|
|
// HTTP side — same app, same route, other transport (plain fasthttp client).
|
|
var httpErr error
|
|
for i := 0; i < 50; i++ {
|
|
code, body, err := fasthttp.Get(nil, "http://"+httpAddr+"/v1/health")
|
|
if err == nil && code == 200 && strings.Contains(string(body), `"status":"ok"`) {
|
|
httpErr = nil
|
|
break
|
|
}
|
|
httpErr = err
|
|
time.Sleep(40 * time.Millisecond)
|
|
}
|
|
if httpErr != nil {
|
|
t.Fatalf("HTTP transport never served: %v", httpErr)
|
|
}
|
|
}
|
|
|
|
// TestHTTPTransport_ReadBufferSize_Raises431Ceiling is the regression test for
|
|
// the bug where zip's HTTP transport built a bare fasthttp.Server and dropped
|
|
// zip.Config.ReadBufferSize — capping request headers at fasthttp's 4 KiB
|
|
// default and returning 431 (Request Header Fields Too Large) once a browser's
|
|
// multi-domain SSO cookies crossed ~4 KiB. It proves both halves: the default
|
|
// still 431s a 9 KiB header (fail-visible if the transport ever stops honoring
|
|
// fasthttp defaults), and ReadBufferSize:32768 now lets the SAME header through.
|
|
func TestHTTPTransport_ReadBufferSize_Raises431Ceiling(t *testing.T) {
|
|
// A ~9 KiB header — well past fasthttp's 4 KiB default, well under 32 KiB.
|
|
req := "GET /v1/health HTTP/1.1\r\nHost: x\r\nX-Big: " +
|
|
strings.Repeat("A", 9000) + "\r\nConnection: close\r\n\r\n"
|
|
|
|
// Control: no ReadBufferSize -> fasthttp's 4 KiB default -> 431.
|
|
ctrl := zip.New(zip.Config{AppName: "ctrl", DisableStartupMessage: true})
|
|
ctrl.Raw("GET", "/v1/health", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) })
|
|
ctrlAddr := freeAddr(t)
|
|
go func() { _ = ctrl.Listen("http://" + ctrlAddr) }()
|
|
defer func() { _ = ctrl.Shutdown() }()
|
|
waitDialable(t, ctrlAddr)
|
|
if code := rawHTTPStatus(t, ctrlAddr, req); code != 431 {
|
|
t.Fatalf("control (default 4 KiB buffer): 9 KiB header -> %d, want 431 (the default cap this fix raises)", code)
|
|
}
|
|
|
|
// Fixed: ReadBufferSize 32 KiB -> the SAME 9 KiB header is accepted.
|
|
fixed := zip.New(zip.Config{AppName: "fixed", DisableStartupMessage: true, ReadBufferSize: 32768})
|
|
fixed.Raw("GET", "/v1/health", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) })
|
|
fixedAddr := freeAddr(t)
|
|
go func() { _ = fixed.Listen("http://" + fixedAddr) }()
|
|
defer func() { _ = fixed.Shutdown() }()
|
|
waitDialable(t, fixedAddr)
|
|
if code := rawHTTPStatus(t, fixedAddr, req); code != 200 {
|
|
t.Fatalf("fixed (ReadBufferSize 32 KiB): 9 KiB header -> %d, want 200 (431 means the knob is still dropped at the wire)", code)
|
|
}
|
|
}
|
|
|
|
// TestHTTPTransport_BodyLimitReachesTheSocket asserts zip.Config.BodyLimit binds
|
|
// the socket in both directions: raised, a body past fasthttp's 4 MiB default is
|
|
// served; lowered, a body past the App's own ceiling is refused. It drives a real
|
|
// listener, because fiber's config and this transport's server are separate
|
|
// objects and only the second one answers the wire.
|
|
func TestHTTPTransport_BodyLimitReachesTheSocket(t *testing.T) {
|
|
post := func(n int) string {
|
|
return fmt.Sprintf("POST /v1/echo HTTP/1.1\r\nHost: x\r\nContent-Type: application/octet-stream\r\n"+
|
|
"Content-Length: %d\r\nConnection: close\r\n\r\n%s", n, strings.Repeat("a", n))
|
|
}
|
|
serve := func(name string, limit int) string {
|
|
app := zip.New(zip.Config{AppName: name, DisableStartupMessage: true, BodyLimit: limit})
|
|
app.Raw("POST", "/v1/echo", func(c *zip.Ctx) error { return c.JSON(200, map[string]int{"n": len(c.Body())}) })
|
|
addr := freeAddr(t)
|
|
go func() { _ = app.Listen("http://" + addr) }()
|
|
t.Cleanup(func() { _ = app.Shutdown() })
|
|
waitDialable(t, addr)
|
|
return addr
|
|
}
|
|
|
|
// Raised: 5 MiB is past fasthttp's 4 MiB default and inside an 8 MiB App.
|
|
if !answered200(t, serve("raised", 8<<20), post(5<<20)) {
|
|
t.Errorf("BodyLimit 8 MiB: 5 MiB body was refused, want 200")
|
|
}
|
|
|
|
// Lowered: 2 MiB is past a 1 MiB App and inside fasthttp's default, so a 200
|
|
// here means the socket is on the default rather than on Config.
|
|
if answered200(t, serve("lowered", 1<<20), post(2<<20)) {
|
|
t.Errorf("BodyLimit 1 MiB: 2 MiB body was served, want a refusal")
|
|
}
|
|
}
|
|
|
|
// answered200 reports whether the handler answered 200. A write or read failure is a
|
|
// refusal, not a test failure: fasthttp drops the connection mid-body when the
|
|
// limit is crossed, so the peer never gets a status line to parse.
|
|
func answered200(t *testing.T, addr, rawRequest string) bool {
|
|
t.Helper()
|
|
c, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial %s: %v", addr, err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
|
if _, err := io.WriteString(c, rawRequest); err != nil {
|
|
return false
|
|
}
|
|
line, err := bufio.NewReader(c).ReadString('\n')
|
|
return err == nil && strings.HasPrefix(line, "HTTP/1.1 200")
|
|
}
|
|
|
|
// TestHTTPTransport_ServerHeaderCoversPreRoutingErrors proves fasthttp's OWN
|
|
// pre-routing error responses (431 on header overflow) carry the App's
|
|
// ServerHeader and NEVER the framework default "fasthttp"/"zip". Those bytes are
|
|
// written before any fiber middleware runs, so ProductionHeaders cannot brand
|
|
// them — the transport must. A REAL socket; Fiber().Test() cannot observe this
|
|
// path (which is exactly how the leak hid from the in-memory suite).
|
|
func TestHTTPTransport_ServerHeaderCoversPreRoutingErrors(t *testing.T) {
|
|
// 9 KiB header overflows fasthttp's default 4 KiB read buffer -> 431, emitted
|
|
// before routing.
|
|
req := "GET /v1/health HTTP/1.1\r\nHost: api.lux.network\r\nX-Big: " +
|
|
strings.Repeat("A", 9000) + "\r\nConnection: close\r\n\r\n"
|
|
|
|
// Branded: the pre-routing 431 carries Server: <brand>, never the framework.
|
|
brand := zip.New(zip.Config{AppName: "brand", DisableStartupMessage: true, ServerHeader: "hanzo"})
|
|
brand.Raw("GET", "/v1/health", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) })
|
|
brandAddr := freeAddr(t)
|
|
go func() { _ = brand.Listen("http://" + brandAddr) }()
|
|
defer func() { _ = brand.Shutdown() }()
|
|
waitDialable(t, brandAddr)
|
|
resp := rawHTTPResponse(t, brandAddr, req)
|
|
if !strings.HasPrefix(resp, "HTTP/1.1 431") {
|
|
t.Fatalf("want a 431 pre-routing error, got:\n%s", resp)
|
|
}
|
|
low := strings.ToLower(resp)
|
|
for _, bad := range []string{"server: fasthttp", "server: zip", "server: fiber"} {
|
|
if strings.Contains(low, bad) {
|
|
t.Errorf("pre-routing 431 leaked framework name %q:\n%s", bad, resp)
|
|
}
|
|
}
|
|
if !strings.Contains(low, "server: hanzo") {
|
|
t.Errorf("pre-routing 431 missing Server: hanzo:\n%s", resp)
|
|
}
|
|
|
|
// Suppressed: ServerHeader "-" emits NO Server header on the pre-routing error.
|
|
quiet := zip.New(zip.Config{AppName: "quiet", DisableStartupMessage: true, ServerHeader: "-"})
|
|
quiet.Raw("GET", "/v1/health", func(c *zip.Ctx) error { return c.JSON(200, map[string]string{"ok": "1"}) })
|
|
quietAddr := freeAddr(t)
|
|
go func() { _ = quiet.Listen("http://" + quietAddr) }()
|
|
defer func() { _ = quiet.Shutdown() }()
|
|
waitDialable(t, quietAddr)
|
|
if low := strings.ToLower(rawHTTPResponse(t, quietAddr, req)); strings.Contains(low, "server:") {
|
|
t.Errorf(`ServerHeader "-" still emitted a Server header on the pre-routing error:\n%s`, low)
|
|
}
|
|
}
|
|
|
|
// rawHTTPResponse writes rawRequest verbatim and returns the FULL raw response
|
|
// (status line + headers + body) so a test can inspect response headers the
|
|
// status code alone hides. Relies on Connection: close so the read ends at EOF.
|
|
func rawHTTPResponse(t *testing.T, addr, rawRequest string) string {
|
|
t.Helper()
|
|
c, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial %s: %v", addr, err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
_ = c.SetDeadline(time.Now().Add(3 * time.Second))
|
|
if _, err := io.WriteString(c, rawRequest); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
b, _ := io.ReadAll(c)
|
|
return string(b)
|
|
}
|
|
|
|
// rawHTTPStatus opens a raw TCP conn, writes rawRequest verbatim (so the test
|
|
// controls exact header bytes, unlike a buffered client), and returns the
|
|
// response status code from the status line.
|
|
func rawHTTPStatus(t *testing.T, addr, rawRequest string) int {
|
|
t.Helper()
|
|
c, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("dial %s: %v", addr, err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
_ = c.SetDeadline(time.Now().Add(3 * time.Second))
|
|
if _, err := io.WriteString(c, rawRequest); err != nil {
|
|
t.Fatalf("write: %v", err)
|
|
}
|
|
line, err := bufio.NewReader(c).ReadString('\n')
|
|
if err != nil {
|
|
t.Fatalf("read status line: %v", err)
|
|
}
|
|
var proto string
|
|
var code int
|
|
if _, err := fmt.Sscanf(line, "%s %d", &proto, &code); err != nil {
|
|
t.Fatalf("parse status line %q: %v", line, err)
|
|
}
|
|
return code
|
|
}
|
|
|
|
// waitDialable blocks until a plain TCP dial to addr succeeds (the HTTP
|
|
// transport is up) or the bound is hit.
|
|
func waitDialable(t *testing.T, addr string) {
|
|
t.Helper()
|
|
for i := 0; i < 100; i++ {
|
|
if c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond); err == nil {
|
|
_ = c.Close()
|
|
return
|
|
}
|
|
time.Sleep(40 * time.Millisecond)
|
|
}
|
|
t.Fatalf("http listener %s never became dialable", addr)
|
|
}
|
|
|
|
func waitReachable(t *testing.T, addr string) {
|
|
t.Helper()
|
|
tr := http.Dial("tcp", addr)
|
|
tr.SetDialTimeout(200 * time.Millisecond)
|
|
defer tr.CloseIdleConnections()
|
|
for i := 0; i < 50; i++ {
|
|
req := fasthttp.AcquireRequest()
|
|
resp := fasthttp.AcquireResponse()
|
|
req.SetRequestURI("/v1/health")
|
|
req.Header.SetMethod(fasthttp.MethodGet)
|
|
err := tr.Do(req, resp)
|
|
fasthttp.ReleaseRequest(req)
|
|
fasthttp.ReleaseResponse(resp)
|
|
if err == nil {
|
|
return
|
|
}
|
|
time.Sleep(40 * time.Millisecond)
|
|
}
|
|
t.Fatalf("ZAP listener at %s never became reachable", addr)
|
|
}
|