Files
zip/form_test.go
T
zeekay 5085333c13 Keep every existing op's wire and close the new kinds' edges
A JSON answer keeps fiber's application/json; charset=utf-8 unless the
op names another media, and every declared status carries the Out's
schema again, a union's alternative filed under the status it states.
A value of the op's own type stating status zero is refused as before;
only Body and Redirect read zero as the op's own. A header field is a
CLI flag under its field's name as it was, and a 3xx with a body is an
answer the remote CLI prints.

form: tags bind a form only when the op consumes a form media type, so
another binder's tags no longer move an op off JSON or refuse a GET.
A cookie: field is set by the request's cookie and nothing else, and no
body schema lists it, so a cross-site body cannot fill it.

Sse.Send takes a ctx that ends with the stream: a tool call collects
for at most toolWait and toolBound bytes, then cancels the producer,
and a stuck producer cannot hold the call. A panicking Reader ends its
answer, not the process. A socket op refuses a request that cannot
upgrade before its handler runs, over REST and the call plane, and a
nil CheckOrigin is same-origin, refused 403. The call plane carries a
union's JSON alternative; Sse and Socket fields that cannot cross say
so with zap:"-". MCP bytes are bounded and untyped bytes take the op's
media.
2026-10-09 09:42:01 -07:00

293 lines
11 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/base64"
"encoding/json"
"mime/multipart"
"net/http/httptest"
"net/textproto"
"net/url"
"strings"
"testing"
"github.com/zap-proto/zip"
)
// A form post — an OAuth token request, an upload with its fields — binds
// `form:` fields and *zip.File parts, and a `cookie:` field reads a request
// cookie. Each is a typed op with the wire it always had.
type tokenIn struct {
Grant string `json:"grant_type" form:"grant_type"`
Code string `json:"code" form:"code"`
Scope []string `json:"scope" form:"scope"`
Device string `json:"device" cookie:"device"`
}
type tokenOut struct {
Grant string `json:"grant_type"`
Code string `json:"code"`
Scope []string `json:"scope"`
Device string `json:"device"`
}
func token(_ context.Context, in *tokenIn) (*tokenOut, error) {
return &tokenOut{Grant: in.Grant, Code: in.Code, Scope: in.Scope, Device: in.Device}, nil
}
// tokenApp registers the token op; with no options it consumes a url-encoded
// form, which is what makes its form: fields a form.
func tokenApp(opts ...zip.OpOption) *zip.App {
if len(opts) == 0 {
opts = []zip.OpOption{zip.Consumes("application/x-www-form-urlencoded")}
}
app := zip.New(zip.Config{AppName: "auth", DisableStartupMessage: true})
zip.Describe("POST /v1/token", zip.Doc{
Description: "Token trades a grant for a token.",
Fields: map[string]string{
"tokenIn.grant_type": "Grant names the grant.",
"tokenIn.code": "Code is the authorization code.",
"tokenIn.scope": "Scope lists what is asked for.",
"tokenIn.device": "Device is the browser's device cookie.",
},
})
app.Post("/v1/token", token, opts...)
return app
}
func postForm(t *testing.T, app *zip.App, media string, body []byte, cookie string) (int, tokenOut) {
t.Helper()
req := httptest.NewRequest("POST", "/v1/token", bytes.NewReader(body))
req.Header.Set("Content-Type", media)
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var out tokenOut
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out
}
func TestForm_BindsFieldsAndCookies(t *testing.T) {
app := tokenApp()
form := url.Values{"grant_type": {"authorization_code"}, "code": {"c1"}, "scope": {"read", "write"}}
code, got := postForm(t, app, "application/x-www-form-urlencoded", []byte(form.Encode()), "device=d9; other=x")
if code != 200 || got.Grant != "authorization_code" || got.Code != "c1" ||
strings.Join(got.Scope, ",") != "read,write" || got.Device != "d9" {
t.Errorf("%d %+v", code, got)
}
}
// A form op reads JSON only when it says it takes JSON. Otherwise a JSON body
// binds nothing, exactly as a form reader finds no fields in it.
func TestForm_ReadsJSONOnlyWhenItSaysSo(t *testing.T) {
body := []byte(`{"grant_type":"refresh_token","code":"c2"}`)
if _, got := postForm(t, tokenApp(), "application/json", body, ""); got.Grant != "" || got.Code != "" {
t.Errorf("a form op bound a JSON body it does not take: %+v", got)
}
both := tokenApp(zip.Consumes("application/x-www-form-urlencoded", "application/json"))
if _, got := postForm(t, both, "application/json", body, ""); got.Grant != "refresh_token" || got.Code != "c2" {
t.Errorf("a form op that takes JSON bound %+v", got)
}
}
func TestForm_IsPublishedAsAForm(t *testing.T) {
op := opIn(t, tokenApp(), "/v1/token", "post")
content := op["requestBody"].(map[string]any)["content"].(map[string]any)
form, ok := content["application/x-www-form-urlencoded"].(map[string]any)
if !ok || len(content) != 1 {
t.Fatalf("content = %v, want one url-encoded form", content)
}
props := form["schema"].(map[string]any)["properties"].(map[string]any)
for name, want := range map[string]string{
"grant_type": "Grant names the grant.", "code": "Code is the authorization code.", "scope": "Scope lists what is asked for.",
} {
if p, _ := props[name].(map[string]any); p["description"] != want {
t.Errorf("form %s = %v", name, props[name])
}
}
if _, ok := props["device"]; ok {
t.Error("the cookie is published as a form field")
}
var cookie map[string]any
for _, p := range op["parameters"].([]any) {
if m := p.(map[string]any); m["in"] == "cookie" {
cookie = m
}
}
if cookie["name"] != "device" || cookie["description"] != "Device is the browser's device cookie." {
t.Errorf("cookie parameter = %v", cookie)
}
}
// A multipart form binds its parts: a *zip.File is one part, a []zip.File every
// part of that name, and the fields beside them bind as a url-encoded form's do.
type uploadIn struct {
Title string `json:"title" form:"title"`
Avatar *zip.File `json:"avatar" form:"avatar"`
Pages []zip.File `json:"pages" form:"pages"`
}
type uploadOut struct {
Title string `json:"title"`
Avatar string `json:"avatar"`
Pages []string `json:"pages"`
}
func upload(_ context.Context, in *uploadIn) (*uploadOut, error) {
out := &uploadOut{Title: in.Title}
if in.Avatar != nil {
out.Avatar = in.Avatar.Name + "|" + in.Avatar.Type + "|" + string(in.Avatar.Bytes)
}
for _, p := range in.Pages {
out.Pages = append(out.Pages, p.Name+"|"+string(p.Bytes))
}
return out, nil
}
func TestForm_BindsMultipartParts(t *testing.T) {
app := zip.New(zip.Config{AppName: "up", DisableStartupMessage: true})
app.Post("/v1/upload", upload)
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
_ = mw.WriteField("title", "Q3")
part := func(name, file, media, body string) {
h := textproto.MIMEHeader{}
h.Set("Content-Disposition", `form-data; name="`+name+`"; filename="`+file+`"`)
h.Set("Content-Type", media)
w, _ := mw.CreatePart(h)
_, _ = w.Write([]byte(body))
}
part("avatar", "me.png", "image/png", "PNG")
part("pages", "1.txt", "text/plain", "one")
part("pages", "2.txt", "text/plain", "two")
_ = mw.Close()
req := httptest.NewRequest("POST", "/v1/upload", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var got uploadOut
_ = json.NewDecoder(resp.Body).Decode(&got)
if resp.StatusCode != 200 || got.Title != "Q3" || got.Avatar != "me.png|image/png|PNG" ||
strings.Join(got.Pages, ",") != "1.txt|one,2.txt|two" {
t.Errorf("%d %+v", resp.StatusCode, got)
}
op := opIn(t, app, "/v1/upload", "post")
content := op["requestBody"].(map[string]any)["content"].(map[string]any)
form, ok := content["multipart/form-data"].(map[string]any)
if !ok {
t.Fatalf("content = %v, want multipart/form-data", content)
}
props := form["schema"].(map[string]any)["properties"].(map[string]any)
if a := props["avatar"].(map[string]any); a["format"] != "binary" {
t.Errorf("avatar = %v, want binary", a)
}
if p := props["pages"].(map[string]any); p["items"].(map[string]any)["format"] != "binary" {
t.Errorf("pages = %v, want a list of binary", p)
}
// Over MCP a part is {name, type, bytes}, its bytes base64.
built(t, app)
env := mcpCall(t, app, zip.ID("POST", "/v1/upload"), map[string]any{
"title": "Q4",
"avatar": map[string]any{"name": "a.png", "type": "image/png", "bytes": base64.StdEncoding.EncodeToString([]byte("PNG"))},
})
var viaMCP uploadOut
if err := json.Unmarshal([]byte(mcpText(t, env["result"].(map[string]any))), &viaMCP); err != nil {
t.Fatal(err)
}
if viaMCP.Title != "Q4" || viaMCP.Avatar != "a.png|image/png|PNG" {
t.Errorf("over MCP: %+v", viaMCP)
}
}
// A cookie field is not a URL value: a link could otherwise set a session.
func TestForm_CookieIsNotAQueryValue(t *testing.T) {
type in struct {
Session string `json:"session" cookie:"session"`
}
app := zip.New(zip.Config{AppName: "c", DisableStartupMessage: true})
app.Get("/v1/me", func(_ context.Context, in *in) (*tokenOut, error) { return &tokenOut{Device: in.Session}, nil })
req := httptest.NewRequest("GET", "/v1/me?session=forged", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatal(err)
}
var got tokenOut
_ = json.NewDecoder(resp.Body).Decode(&got)
if got.Device != "" {
t.Errorf("a query value set the cookie field: %q", got.Device)
}
for _, p := range opIn(t, app, "/v1/me", "get")["parameters"].([]any) {
if m := p.(map[string]any); m["in"] == "query" {
t.Errorf("the cookie is published as a query parameter: %v", m)
}
}
}
// form: tags alone are another binder's convention, and an op that consumes no
// form keeps the wire it had: a GET registers and binds its query, a POST binds
// its JSON body.
func TestForm_TagsAloneAreAnotherBinders(t *testing.T) {
type searchIn struct {
Q string `json:"q" form:"q"`
}
app := zip.New(zip.Config{AppName: "g", DisableStartupMessage: true})
app.Get("/v1/search", func(_ context.Context, in *searchIn) (*tokenOut, error) { return &tokenOut{Code: in.Q}, nil })
app.Post("/v1/token", token)
resp, err := app.Test(httptest.NewRequest("GET", "/v1/search?q=hi", nil))
if err != nil {
t.Fatal(err)
}
var got tokenOut
_ = json.NewDecoder(resp.Body).Decode(&got)
if resp.StatusCode != 200 || got.Code != "hi" {
t.Errorf("GET: %d %+v", resp.StatusCode, got)
}
code, got := postForm(t, app, "application/json", []byte(`{"grant_type":"g","code":"c"}`), "")
if code != 200 || got.Grant != "g" || got.Code != "c" {
t.Errorf("POST: %d %+v", code, got)
}
content := opIn(t, app, "/v1/token", "post")["requestBody"].(map[string]any)["content"].(map[string]any)
if _, ok := content["application/json"]; !ok || len(content) != 1 {
t.Errorf("content = %v, want JSON as before", content)
}
}
// A cookie field is set by the request's cookie and by nothing else: a body
// that names it, which a page on another site can make a browser send, sets
// nothing.
func TestForm_CookieIsNotABodyValue(t *testing.T) {
both := tokenApp(zip.Consumes("application/x-www-form-urlencoded", "application/json"))
body := []byte(`{"grant_type":"g","device":"forged"}`)
if _, got := postForm(t, both, "text/plain", body, ""); got.Device != "" {
t.Errorf("a text/plain body set the cookie field: %q", got.Device)
}
if _, got := postForm(t, both, "application/json", body, ""); got.Device != "" || got.Grant != "g" {
t.Errorf("a JSON body set the cookie field: %+v", got)
}
if _, got := postForm(t, both, "application/json", body, "device=d1"); got.Device != "d1" {
t.Errorf("the cookie did not set its field: %+v", got)
}
props := opIn(t, both, "/v1/token", "post")["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"]
if b, _ := json.Marshal(props); strings.Contains(string(b), "device") {
t.Errorf("the JSON body schema publishes the cookie: %s", b)
}
}