mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
A socket op's handshake is decided before its handler runs, so a handler with effects runs only for a connection it will get: 426 for another protocol version, 400 without a key, and 403 for a browser on an origin the op does not admit — its own, plus zip.Origins(...). The origin policy is the op's, not the answer's, so Socket carries only Serve, Subprotocols and Compression, and wsx's own refusals are back to the library's. The in-process CLI refuses a socket op before invoking it. An op that answers JSON or bytes keeps JSON's media for its JSON when Produces names the bytes'. 204, 304 and a redirecting op's 3xx carry no body in the document; any other declared status, 3xx included, carries the Out and the doc comment's example, as before. An MCP read of a Reader is bounded in time as a stream is, and the reader closed. TestConnMemory counts the goroutines holding connections added, as it counts the heap they added, so goroutines other tests leave winding down are not charged to the server.
326 lines
12 KiB
Go
326 lines
12 KiB
Go
// SPDX-License-Identifier: BSD-3-Clause-Eco
|
|
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package zip_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/fasthttp/websocket"
|
|
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
// The new kinds leave every op that predates them where it was, and each kind
|
|
// holds the edges an adversary reaches for. Each test here is one of those
|
|
// edges, driven.
|
|
|
|
type multiOut struct {
|
|
// ID names the record.
|
|
ID string `json:"id"`
|
|
code int
|
|
}
|
|
|
|
func (m multiOut) StatusCode() int { return m.code }
|
|
|
|
// An op that declares several statuses answers its Out under each, so each
|
|
// carries the Out's schema — a held op's 202 included.
|
|
func TestKinds_EveryDeclaredStatusCarriesTheAnswer(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "multi", DisableStartupMessage: true})
|
|
app.Post("/v1/records", func(context.Context, *struct{}) (*multiOut, error) {
|
|
return &multiOut{ID: "r1", code: 201}, nil
|
|
}, zip.WithStatus(200, 201, 202))
|
|
responses := opIn(t, app, "/v1/records", "post")["responses"].(map[string]any)
|
|
for _, code := range []string{"200", "201", "202"} {
|
|
entry := responses[code].(map[string]any)
|
|
content, _ := entry["content"].(map[string]any)
|
|
media, _ := content["application/json"].(map[string]any)
|
|
if b, _ := json.Marshal(media["schema"]); !strings.Contains(string(b), "multiOut") {
|
|
t.Errorf("%s = %v, want the Out's schema", code, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
type quietOut struct {
|
|
OK bool `json:"ok"`
|
|
}
|
|
|
|
func (quietOut) StatusCode() int { return 0 }
|
|
|
|
// A value of the op's own type that states zero states a status it did not
|
|
// declare, and is refused as it always was; only zip's Body and Redirect read
|
|
// zero as the op's own.
|
|
func TestKinds_ZeroIsUndeclaredForAnOrdinaryValue(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "zero", DisableStartupMessage: true})
|
|
app.Get("/v1/quiet", func(context.Context, *struct{}) (*quietOut, error) { return &quietOut{OK: true}, nil })
|
|
resp, err := app.Test(httptest.NewRequest("GET", "/v1/quiet", nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != 500 {
|
|
t.Errorf("a StatusCode of 0 answered %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
type tenantIn struct {
|
|
// Tenant is the tenant the request is for.
|
|
Tenant string `json:"tenant" header:"X-Tenant"`
|
|
}
|
|
|
|
// A field a header binds is a flag under its field's name, as it always was,
|
|
// and it reaches the op.
|
|
func TestKinds_AHeaderFieldKeepsItsFlag(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "things", DisableStartupMessage: true})
|
|
app.Get("/v1/things/:id", func(_ context.Context, in *struct {
|
|
ID string `json:"id" url:"id"`
|
|
Tenant string `json:"tenant" header:"X-Tenant"`
|
|
}) (*mkOut, error) {
|
|
return &mkOut{ID: in.Tenant + "/" + in.ID}, nil
|
|
})
|
|
var out bytes.Buffer
|
|
cli := app.CLI()
|
|
cli.Out = &out
|
|
cmds := app.Commands()
|
|
if err := cli.Run(context.Background(), argv(t, cmds, "GET", "/v1/things/:id", "x1", "--tenant", "acme")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out.String(), `"acme/x1"`) {
|
|
t.Errorf("printed %q", out.String())
|
|
}
|
|
}
|
|
|
|
// A reader that panics ends its answer, not the process: the next request is
|
|
// answered.
|
|
func TestKinds_APanickingReaderEndsOnlyItsAnswer(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "dl", DisableStartupMessage: true})
|
|
app.Get("/v1/bad", func(context.Context, *struct{}) (*zip.Body, error) {
|
|
return &zip.Body{Type: "text/plain", Reader: panicky{}}, nil
|
|
})
|
|
app.Get("/v1/good", func(context.Context, *struct{}) (*mkOut, error) { return &mkOut{ID: "ok"}, nil })
|
|
addr := serveHTTP(t, app)
|
|
// No keep-alive: a connection left idle would outlive the test.
|
|
client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
|
|
if resp, err := client.Get("http://" + addr + "/v1/bad"); err == nil {
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
_ = resp.Body.Close()
|
|
}
|
|
resp, err := client.Get("http://" + addr + "/v1/good")
|
|
if err != nil {
|
|
t.Fatalf("the process did not survive: %v", err)
|
|
}
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("good answered %d", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
type panicky struct{}
|
|
|
|
func (panicky) Read([]byte) (int, error) { panic("reader broke") }
|
|
|
|
// Over MCP, bytes past the tool bound are cut and say so, and bytes that state
|
|
// no type are the op's own media, not text.
|
|
func TestKinds_ToolBytesAreBoundedAndTyped(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "blob", DisableStartupMessage: true})
|
|
app.Get("/v1/big", func(context.Context, *struct{}) (*zip.Body, error) {
|
|
return &zip.Body{Type: "text/plain", Bytes: bytes.Repeat([]byte("a"), 3<<20)}, nil
|
|
})
|
|
app.Get("/v1/raw", func(context.Context, *struct{}) (*zip.Body, error) {
|
|
return &zip.Body{Bytes: []byte{0xff, 0xfe, 0x00, 0x80, 'P', 'N', 'G'}}, nil
|
|
})
|
|
built(t, app)
|
|
big := mcpCall(t, app, zip.ID("GET", "/v1/big"), nil)["result"].(map[string]any)
|
|
if n := len(mcpText(t, big)); n != 1<<20 {
|
|
t.Errorf("tool text is %d bytes, want the 1 MiB bound", n)
|
|
}
|
|
if meta, _ := big["_meta"].(map[string]any); meta["truncated"] != true {
|
|
t.Errorf("_meta = %v, want truncated", big["_meta"])
|
|
}
|
|
raw := mcpCall(t, app, zip.ID("GET", "/v1/raw"), nil)["result"].(map[string]any)
|
|
first := raw["content"].([]any)[0].(map[string]any)
|
|
if first["type"] != "resource" || first["resource"].(map[string]any)["mimeType"] != "application/octet-stream" {
|
|
t.Errorf("untyped bytes came back as %v", first)
|
|
}
|
|
}
|
|
|
|
// A WebSocket op refuses a request that cannot become its connection before
|
|
// its handler runs — no upgrade, another version, no key, a browser on an
|
|
// origin it does not admit, the in-process CLI — and admits the origins it
|
|
// names.
|
|
func TestKinds_ASocketRefusesBeforeItsHandler(t *testing.T) {
|
|
var ran atomic.Int32
|
|
live := func(context.Context, *struct{}) (*zip.Socket[line], error) {
|
|
ran.Add(1)
|
|
return &zip.Socket[line]{Serve: func(*websocket.Conn) error { return nil }}, nil
|
|
}
|
|
app := zip.New(zip.Config{AppName: "ws", DisableStartupMessage: true})
|
|
app.Get("/v1/live", live)
|
|
app.Get("/v1/open", live, zip.Origins("https://app.example"))
|
|
resp, err := app.Test(httptest.NewRequest("GET", "/v1/live", nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != 426 || ran.Load() != 0 {
|
|
t.Errorf("a plain GET answered %d and ran the handler %d times", resp.StatusCode, ran.Load())
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
header map[string]string
|
|
code int
|
|
}{
|
|
{"version 8", map[string]string{"Sec-WebSocket-Version": "8"}, 426},
|
|
{"versions 8 and 7", map[string]string{"Sec-WebSocket-Version": "8, 7"}, 426},
|
|
{"no key", map[string]string{"Sec-WebSocket-Key": ""}, 400},
|
|
{"another site", map[string]string{"Origin": "https://elsewhere.example"}, 403},
|
|
{"opaque origin", map[string]string{"Origin": "null"}, 403},
|
|
} {
|
|
req := httptest.NewRequest("GET", "/v1/live", nil)
|
|
req.Header.Set("Connection", "Upgrade")
|
|
req.Header.Set("Upgrade", "websocket")
|
|
req.Header.Set("Sec-WebSocket-Version", "13")
|
|
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
|
|
for k, v := range c.header {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := app.Test(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.StatusCode != c.code || ran.Load() != 0 {
|
|
t.Errorf("%s answered %d, want %d; the handler ran %d times", c.name, resp.StatusCode, c.code, ran.Load())
|
|
}
|
|
}
|
|
if err := app.CLI().Run(context.Background(), argv(t, app.Commands(), "GET", "/v1/live")); err == nil || ran.Load() != 0 {
|
|
t.Errorf("the in-process CLI answered %v and ran the handler %d times", err, ran.Load())
|
|
}
|
|
|
|
addr := serveHTTP(t, app)
|
|
d := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
|
for _, c := range []struct {
|
|
path, origin string
|
|
ok bool
|
|
}{
|
|
{"/v1/live", "http://" + addr, true},
|
|
{"/v1/live", "https://app.example", false},
|
|
{"/v1/open", "https://app.example", true},
|
|
{"/v1/open", "https://elsewhere.example", false},
|
|
} {
|
|
conn, resp, err := d.Dial("ws://"+addr+c.path, http.Header{"Origin": {c.origin}})
|
|
if err == nil {
|
|
_ = conn.Close()
|
|
}
|
|
if c.ok != (err == nil) || !c.ok && (resp == nil || resp.StatusCode != 403) {
|
|
t.Errorf("%s from %s: %v, %v", c.path, c.origin, resp, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A 3xx that carries a body is read as an answer by the remote CLI; one that
|
|
// carries none is a redirect.
|
|
func TestKinds_ARedirectWithABodyIsAnAnswer(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "moved", DisableStartupMessage: true})
|
|
app.Get("/v1/moved", func(context.Context, *struct{}) (*multiOut, error) {
|
|
return &multiOut{ID: "there", code: 303}, nil
|
|
}, zip.WithStatus(303), zip.WithResponseHeader("Location"))
|
|
addr := serveHTTP(t, app)
|
|
remote := zip.Remote{Base: "http://" + addr}
|
|
spec, err := remote.Spec(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmds, err := zip.CommandsFromSpec(spec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out bytes.Buffer
|
|
cli := &zip.CLI{Name: "moved", Commands: cmds, Invoke: remote.Invoke, Out: &out}
|
|
if err := cli.Run(context.Background(), argv(t, cmds, "GET", "/v1/moved")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out.String(), `"there"`) {
|
|
t.Errorf("printed %q, want the body", out.String())
|
|
}
|
|
}
|
|
|
|
// The JSON alternative of an answer that may also stream crosses the call
|
|
// plane as the union it is.
|
|
func TestKinds_AValueOrStreamCrossesTheCallPlane(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "either", DisableStartupMessage: true})
|
|
app.Post("/v1/either", func(context.Context, *struct{}) (*zip.Or[mkOut, zip.Sse[mkOut]], error) {
|
|
return &zip.Or[mkOut, zip.Sse[mkOut]]{A: &mkOut{ID: "whole"}}, nil
|
|
}, zip.WithOperationID("either"))
|
|
c, err := zip.Dial(serveUDS(t, app))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = c.Close() }()
|
|
got, err := zip.Call[struct{}, zip.Or[mkOut, zip.Sse[mkOut]]](context.Background(), c, "either", &struct{}{})
|
|
if err != nil {
|
|
t.Fatalf("call: %v", err)
|
|
}
|
|
if got.A == nil || got.A.ID != "whole" || got.B != nil {
|
|
t.Errorf("got %+v", got)
|
|
}
|
|
}
|
|
|
|
type metaOut struct {
|
|
// W is the width.
|
|
W int `json:"w"`
|
|
}
|
|
|
|
// An op that answers JSON or image bytes names the bytes' media with Produces,
|
|
// and its JSON still goes out as JSON.
|
|
func TestKinds_JSONBesideBytesStaysJSON(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "img", DisableStartupMessage: true})
|
|
app.Get("/v1/thumb", func(context.Context, *struct{}) (*zip.Or[metaOut, zip.Body], error) {
|
|
return &zip.Or[metaOut, zip.Body]{A: &metaOut{W: 64}}, nil
|
|
}, zip.Produces("image/png"))
|
|
resp, err := app.Test(httptest.NewRequest("GET", "/v1/thumb", nil))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := io.ReadAll(resp.Body)
|
|
if ct := resp.Header.Get("Content-Type"); ct != "application/json; charset=utf-8" || string(b) != `{"w":64}` {
|
|
t.Errorf("answered %q %s", ct, b)
|
|
}
|
|
}
|
|
|
|
// A 3xx that is not a redirect carries the body it sends, 204 and 304 carry
|
|
// none, and a redirect's 3xx carries a Location and no body. The doc comment's
|
|
// example rides every status that answers the Out.
|
|
func TestKinds_WhichStatusesCarryABody(t *testing.T) {
|
|
app := zip.New(zip.Config{AppName: "st", DisableStartupMessage: true})
|
|
zip.Describe("POST /v1/see", zip.Doc{Description: "See answers.", Response: json.RawMessage(`{"id":"x"}`)})
|
|
app.Post("/v1/see", func(context.Context, *struct{}) (*multiOut, error) {
|
|
return &multiOut{ID: "x", code: 302}, nil
|
|
}, zip.WithStatus(200, 201, 302, 304))
|
|
app.Get("/v1/go", func(context.Context, *struct{}) (*zip.Redirect, error) {
|
|
return &zip.Redirect{To: "/"}, nil
|
|
}, zip.WithStatus(307))
|
|
see := opIn(t, app, "/v1/see", "post")["responses"].(map[string]any)
|
|
for code, want := range map[string]bool{"200": true, "201": true, "302": true, "304": false} {
|
|
entry := see[code].(map[string]any)
|
|
content, has := entry["content"].(map[string]any)
|
|
if has != want {
|
|
t.Errorf("%s carries content %v, want %v", code, has, want)
|
|
continue
|
|
}
|
|
if want && content["application/json"].(map[string]any)["example"] == nil {
|
|
t.Errorf("%s carries no example", code)
|
|
}
|
|
}
|
|
if r := opIn(t, app, "/v1/go", "get")["responses"].(map[string]any)["307"].(map[string]any); r["content"] != nil {
|
|
t.Errorf("a redirect's 307 carries content: %v", r)
|
|
}
|
|
}
|