mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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.
293 lines
11 KiB
Go
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)
|
|
}
|
|
}
|