mirror of
https://github.com/soypat/lneto.git
synced 2026-08-04 06:57:53 +00:00
d05cd14018
* httphi: RouterConfig refactor to enable DefaultRouterConfig * RequestHeader not case sensitive anymore * httphi: use DefaultRouterConfig in examples * httphi: remove gated stage complexity Misusing Stage methods by calling them once header has been written is totally harmless as far as I can tell. We simplify the codebase on this occasion by removing the headerWritten check for all stage methods * httphi: improve APIs * httphi: remove status
1647 lines
56 KiB
Go
1647 lines
56 KiB
Go
package httphi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/soypat/lneto/http/httpraw"
|
|
|
|
"github.com/soypat/lneto"
|
|
)
|
|
|
|
func nopBackoff(consecutiveBackoffs uint) time.Duration { return lneto.BackoffFlagNop }
|
|
|
|
// defaultNumHeaderKVCap is the field table tests get unless they set their own:
|
|
// room for a realistic request, the sizing being what the test is about only
|
|
// where it says so.
|
|
const defaultNumHeaderKVCap = 32
|
|
|
|
// defaultKVCap is the pair table size tests hand to [httpraw.Form.Reset], a
|
|
// bounded form needing room for the pairs it parses. See [httpraw.Form.Reset].
|
|
const defaultKVCap = 8
|
|
|
|
// newExchange returns an Exchange acquired on conn, ready to serve a request.
|
|
func newExchange(t *testing.T, conn conn, cfg ExchangeConfig) *Exchange {
|
|
t.Helper()
|
|
exch := new(Exchange)
|
|
if cfg.NumHeaderKVCap == 0 {
|
|
cfg.NumHeaderKVCap = defaultNumHeaderKVCap
|
|
}
|
|
exch.Configure(cfg)
|
|
if !exch.Acquire(conn) {
|
|
t.Fatal("fresh exchange failed to acquire connection")
|
|
}
|
|
return exch
|
|
}
|
|
|
|
// serve runs a single exchange to completion on the calling goroutine.
|
|
func serve(t *testing.T, request string, mux Mux) *rwconn {
|
|
t.Helper()
|
|
const bufferSize = 1024
|
|
conn := newConn(request)
|
|
conn.Hangup() // Whole request already pending, nothing more will arrive.
|
|
exch := newExchange(t, conn, ExchangeConfig{
|
|
RawBuf: make([]byte, 2*bufferSize),
|
|
RequestBufferLim: bufferSize,
|
|
})
|
|
err := Handle(exch, mux, nopBackoff)
|
|
if err != nil {
|
|
t.Fatalf("Handle(%q): %s", request, err)
|
|
}
|
|
return conn
|
|
}
|
|
|
|
// WriteHeader must emit a complete status line terminated in CRLF followed by
|
|
// the end-of-headers CRLF, for every status code including the longest text.
|
|
func TestExchangeWriteHeader(t *testing.T) {
|
|
var buf [128]byte
|
|
for _, test := range []struct {
|
|
code int
|
|
want string
|
|
}{
|
|
{code: 200, want: "HTTP/1.1 200 OK\r\n\r\n"},
|
|
{code: 404, want: "HTTP/1.1 404 Not Found\r\n\r\n"},
|
|
{code: 500, want: "HTTP/1.1 500 Internal Server Error\r\n\r\n"},
|
|
// Longest status text in status.go: worst case for the status line buffer.
|
|
{code: 511, want: "HTTP/1.1 511 Network Authentication Required\r\n\r\n"},
|
|
} {
|
|
t.Run(strconv.Itoa(test.code), func(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64})
|
|
exch.WriteHeader(test.code)
|
|
if got := conn.ViewWritten(); got != test.want {
|
|
t.Errorf("want %q, got %q", test.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Status line is written once: a second WriteHeader must not reach the wire.
|
|
func TestExchangeWriteHeaderOnce(t *testing.T) {
|
|
var buf [128]byte
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64})
|
|
exch.WriteHeader(404)
|
|
exch.WriteHeader(500)
|
|
const want = "HTTP/1.1 404 Not Found\r\n\r\n"
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// Write with no prior WriteHeader must flush a 200 header ahead of the body.
|
|
func TestExchangeWriteFlushesHeader(t *testing.T) {
|
|
var buf [128]byte
|
|
const body = "hello"
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: buf[:], RequestBufferLim: 64})
|
|
n, err := exch.WriteBody([]byte(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != len(body) {
|
|
t.Errorf("want %d bytes written, got %d", len(body), n)
|
|
}
|
|
const want = "HTTP/1.1 200 OK\r\n\r\n" + body
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
func TestExchangeSetHeader(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
normalize bool
|
|
set [][2]string
|
|
want string // Header block emitted after the status line.
|
|
}{
|
|
{name: "none", want: "\r\n"},
|
|
{name: "single", set: [][2]string{{"Content-Type", "text/plain"}}, want: "Content-Type:text/plain\r\n\r\n"},
|
|
{
|
|
name: "multiple",
|
|
set: [][2]string{{"Content-Type", "text/plain"}, {"Content-Length", "5"}},
|
|
want: "Content-Type:text/plain\r\nContent-Length:5\r\n\r\n",
|
|
},
|
|
{
|
|
name: "normalized key",
|
|
normalize: true,
|
|
set: [][2]string{{"content-TYPE", "text/plain"}},
|
|
want: "Content-Type:text/plain\r\n\r\n",
|
|
},
|
|
{
|
|
name: "key kept verbatim when not normalizing",
|
|
set: [][2]string{{"content-TYPE", "text/plain"}},
|
|
want: "content-TYPE:text/plain\r\n\r\n",
|
|
},
|
|
{name: "empty value", set: [][2]string{{"X-Empty", ""}}, want: "X-Empty:\r\n\r\n"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 128), RequestBufferLim: 64, NormalizeOutgoingKeys: test.normalize})
|
|
for _, kv := range test.set {
|
|
if !exch.StageHeader(kv[0], kv[1]) {
|
|
t.Fatalf("SetHeader(%q,%q) reported insufficient memory", kv[0], kv[1])
|
|
}
|
|
}
|
|
exch.WriteHeader(200)
|
|
got, found := strings.CutPrefix(conn.ViewWritten(), "HTTP/1.1 200 OK\r\n")
|
|
if !found {
|
|
t.Fatalf("want 200 status line, got %q", conn.ViewWritten())
|
|
}
|
|
if got != test.want {
|
|
t.Errorf("want header block %q, got %q", test.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// SetHeader must refuse to write past the buffer and say so, never panic nor
|
|
// emit a truncated field.
|
|
func TestExchangeSetHeaderOOM(t *testing.T) {
|
|
const bufferSize = 32
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*bufferSize), RequestBufferLim: bufferSize})
|
|
if exch.StageHeader("X-Big", strings.Repeat("v", 4*bufferSize)) {
|
|
t.Fatal("want insufficient memory reported for oversized header value")
|
|
}
|
|
exch.WriteHeader(200)
|
|
if got := conn.ViewWritten(); strings.Contains(got, "X-Big") {
|
|
t.Errorf("dropped header must not appear in response, got %q", got)
|
|
}
|
|
}
|
|
|
|
// Request line and fields the handler observes, over a spread of well formed
|
|
// and awkward but legal request headers.
|
|
func TestHandleRequestFields(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
request string
|
|
wantMethod string
|
|
wantURI string
|
|
wantHost string
|
|
}{
|
|
{
|
|
name: "minimal",
|
|
request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
wantMethod: "GET", wantURI: "/", wantHost: "h",
|
|
},
|
|
{
|
|
name: "query string",
|
|
request: "GET /search?q=go&n=1 HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
wantMethod: "GET", wantURI: "/search?q=go&n=1", wantHost: "h",
|
|
},
|
|
{
|
|
name: "no fields",
|
|
request: "GET /x HTTP/1.1\r\n\r\n",
|
|
wantMethod: "GET", wantURI: "/x", wantHost: "",
|
|
},
|
|
{
|
|
name: "post",
|
|
request: "POST /submit HTTP/1.1\r\nHost: h\r\nContent-Length: 0\r\n\r\n",
|
|
wantMethod: "POST", wantURI: "/submit", wantHost: "h",
|
|
},
|
|
{
|
|
name: "extension method",
|
|
request: "FROBNICATE / HTTP/1.1\r\nHost: h\r\n\r\n",
|
|
wantMethod: "FROBNICATE", wantURI: "/", wantHost: "h",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var gotMethod, gotURI, gotHost string
|
|
var sm MuxSlice
|
|
route, _, _ := strings.Cut(test.wantURI, "?") // Mux matches on path.
|
|
sm.Handle(route, func(ex *Exchange) {
|
|
gotMethod = string(ex.RequestMethodBytes())
|
|
gotURI = string(ex.RequestTarget())
|
|
gotHost = string(ex.RequestHeader("Host"))
|
|
ex.WriteHeader(200)
|
|
})
|
|
serve(t, test.request, &sm)
|
|
|
|
if gotMethod != test.wantMethod {
|
|
t.Errorf("want method %q, got %q", test.wantMethod, gotMethod)
|
|
}
|
|
if gotURI != test.wantURI {
|
|
t.Errorf("want URI %q, got %q", test.wantURI, gotURI)
|
|
}
|
|
if gotHost != test.wantHost {
|
|
t.Errorf("want Host %q, got %q", test.wantHost, gotHost)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Malformed request headers must fail the exchange, never reach a handler.
|
|
func TestHandleMalformedRequest(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
request string
|
|
}{
|
|
{name: "no colon in field", request: "GET / HTTP/1.1\r\nBadFieldNoColon\r\n\r\n"},
|
|
{name: "no protocol", request: "GET /\r\nHost: h\r\n\r\n"},
|
|
{name: "empty request line", request: "\r\n\r\n"},
|
|
{name: "truncated header", request: "GET / HTTP/1.1\r\nHost: h\r\n"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var handled bool
|
|
var sm MuxSlice
|
|
sm.Handle("/", func(ex *Exchange) { handled = true })
|
|
conn := newConn(test.request)
|
|
conn.Hangup()
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
if err := Handle(exch, &sm, nopBackoff); err == nil {
|
|
t.Error("want error on malformed request, got nil")
|
|
}
|
|
if handled {
|
|
t.Error("handler must not run on malformed request")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A request-line naming a version this package does not speak must be answered
|
|
// 505 without reaching a handler, RFC 9112 2.6. The third token is not
|
|
// validated by the parser, so anything that is not HTTP/1.0 or HTTP/1.1 lands
|
|
// here: bogus versions, non-HTTP tokens, and the tail of a request-target that
|
|
// contained a space and got split across the URI/protocol boundary.
|
|
func TestHandleUnsupportedProtocol(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
request string
|
|
}{
|
|
{name: "future major", request: "GET / HTTP/6.9\r\nHost: h\r\n\r\n"},
|
|
{name: "future major keepalive", request: "GET / HTTP/6.9\r\nHost: h\r\nConnection: keep-alive\r\n\r\n"},
|
|
{name: "http2", request: "GET / HTTP/2.0\r\nHost: h\r\n\r\n"},
|
|
{name: "long minor", request: "GET / HTTP/1.10\r\nHost: h\r\n\r\n"},
|
|
{name: "lowercase", request: "GET / http/1.1\r\nHost: h\r\n\r\n"},
|
|
{name: "not http", request: "GET / BANANA\r\nHost: h\r\n\r\n"},
|
|
{name: "space in target", request: "GET /a b HTTP/1.1\r\nHost: h\r\n\r\n"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var handled bool
|
|
var sm MuxSlice
|
|
sm.Handle("/", func(ex *Exchange) { handled = true })
|
|
sm.Handle("/a", func(ex *Exchange) { handled = true })
|
|
conn := newConn(test.request)
|
|
conn.Hangup()
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
if err := Handle(exch, &sm, nopBackoff); err == nil {
|
|
t.Error("want error on unsupported protocol, got nil")
|
|
}
|
|
if handled {
|
|
t.Error("handler must not run on unsupported protocol")
|
|
}
|
|
const want = "HTTP/1.1 505 HTTP Version Not Supported\r\n\r\n"
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// HTTP/1.0 predates HTTP/1.1 but is still served: only the connection is not
|
|
// kept alive. It must not be swept up by the 505 gate.
|
|
func TestHandleHTTP10Served(t *testing.T) {
|
|
var sm MuxSlice
|
|
sm.Handle("/", func(ex *Exchange) { ex.WriteHeader(200) })
|
|
conn := serve(t, "GET / HTTP/1.0\r\nHost: h\r\n\r\n", &sm)
|
|
const want = "HTTP/1.1 200 OK\r\n\r\n"
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// No registered handler must yield 404, not an empty response.
|
|
func TestHandleNoHandler(t *testing.T) {
|
|
var sm MuxSlice
|
|
// "/{$}" is the root and nothing else; a bare "/" is a catch-all that would
|
|
// match /nowhere too, see [SetPathValues].
|
|
sm.Handle("GET /{$}", func(ex *Exchange) { t.Error("handler must not run") })
|
|
conn := serve(t, "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
|
const want = "HTTP/1.1 404 Not Found\r\n\r\n"
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// A handler that writes nothing must still produce a valid response.
|
|
func TestHandleSilentHandler(t *testing.T) {
|
|
var sm MuxSlice
|
|
sm.Handle("/", func(ex *Exchange) {})
|
|
conn := serve(t, "GET / HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
|
const want = "HTTP/1.1 200 OK\r\n\r\n"
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// Body bytes arriving in the same segment as the header must be readable.
|
|
var _ io.ReadWriteCloser = (*ExchangeRW)(nil)
|
|
|
|
// ExchangeRW writes the response body and reads the request body, so it may be
|
|
// handed to code that wants an io.ReadWriter.
|
|
func TestExchangeRW(t *testing.T) {
|
|
const body = "hello"
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*128), RequestBufferLim: 128})
|
|
var rw ExchangeRW
|
|
exch.ReadWriter(&rw)
|
|
|
|
n, err := io.WriteString(&rw, body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != len(body) {
|
|
t.Errorf("want %d bytes written, got %d", len(body), n)
|
|
}
|
|
const want = "HTTP/1.1 200 OK\r\n\r\n" + body
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
}
|
|
|
|
// The exchange is pooled and reused: a handle kept past the request it was
|
|
// taken from must fail instead of reaching the next request's connection.
|
|
func TestExchangeRWOutlivesExchange(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*128), RequestBufferLim: 128})
|
|
var rw ExchangeRW
|
|
exch.ReadWriter(&rw)
|
|
if !rw.IsValid() {
|
|
t.Fatal("want a fresh handle to be valid")
|
|
}
|
|
exch.Release()
|
|
|
|
if rw.IsValid() {
|
|
t.Error("want handle invalidated by release")
|
|
}
|
|
if _, err := rw.Write([]byte("late")); err == nil {
|
|
t.Error("want error writing through a released exchange, got nil")
|
|
}
|
|
if _, err := rw.Read(make([]byte, 4)); err == nil {
|
|
t.Error("want error reading through a released exchange, got nil")
|
|
}
|
|
if got := conn.ViewWritten(); strings.Contains(got, "late") {
|
|
t.Errorf("late write reached the connection: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExchangeReadBody(t *testing.T) {
|
|
const body = "message body"
|
|
var got string
|
|
var readErr error
|
|
var sm MuxSlice
|
|
sm.Handle("POST /", func(ex *Exchange) {
|
|
dst := make([]byte, len(body))
|
|
n, err := ex.ReadBody(dst)
|
|
got, readErr = string(dst[:n]), err
|
|
ex.WriteHeader(200)
|
|
})
|
|
serve(t, "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 12\r\n\r\n"+body, &sm)
|
|
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if got != body {
|
|
t.Errorf("want body %q, got %q", body, got)
|
|
}
|
|
}
|
|
|
|
// SetHeader must budget every byte it writes: colon, CRLF, and the CRLF that
|
|
// FlushHeader appends after the last field. Buffers that fit all but the last
|
|
// byte must be refused, never overrun.
|
|
func TestExchangeStageOKAndFail(t *testing.T) {
|
|
// Long enough that the buffers under test clear the smallest a header
|
|
// buffer may be, while still being sized to the byte around the field.
|
|
const key, value = "X-A-Header-Field-Key", "a-header-field-value"
|
|
const field = len(key) + len(value) + len(":\r\n")
|
|
const numHeaderCap = 4
|
|
for _, bufLen := range []int{field + 2, field + 1, field} {
|
|
t.Run("buffer"+strconv.Itoa(bufLen), func(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := new(Exchange)
|
|
exch.Configure(ExchangeConfig{
|
|
RawBuf: make([]byte, bufLen),
|
|
RequestBufferLim: bufLen,
|
|
NumHeaderKVCap: numHeaderCap,
|
|
})
|
|
if !exch.Acquire(conn) {
|
|
t.Fatal("fresh exchange failed to acquire connection")
|
|
}
|
|
set := exch.StageHeader(key, value)
|
|
n, err := exch.FlushHeader()
|
|
|
|
want := "HTTP/1.1 200 OK\r\n"
|
|
if set {
|
|
want += key + ":" + value + "\r\n"
|
|
want += "\r\n"
|
|
} else {
|
|
if err != lneto.ErrBufferFull || n != 0 {
|
|
t.Fatal("expected buffer full and no data written:", err, n)
|
|
}
|
|
want = ""
|
|
}
|
|
if got := conn.ViewWritten(); got != want {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
if wantSet := bufLen >= field+2; set != wantSet {
|
|
t.Errorf("want SetHeader=%v, got %v", wantSet, set)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle never closes the connection, on any outcome: the caller owns it so
|
|
// that error policy and connection reuse stay the caller's decision.
|
|
func TestHandleLeavesConnOpen(t *testing.T) {
|
|
var sm MuxSlice
|
|
sm.Handle("GET /", staticPage(t, "ok"))
|
|
for _, test := range []struct {
|
|
name string
|
|
request string
|
|
}{
|
|
{name: "served", request: "GET / HTTP/1.1\r\nHost: h\r\n\r\n"},
|
|
{name: "404", request: "GET /nowhere HTTP/1.1\r\nHost: h\r\n\r\n"},
|
|
{name: "rejected no http version", request: "GET /\r\nHost: h\r\n\r\n"},
|
|
{name: "rejected parse error", request: "GET / HTTP/1.1\r\nBadFieldNoColon\r\n\r\n"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
conn := newConn(test.request)
|
|
conn.Hangup()
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
Handle(exch, &sm, nopBackoff)
|
|
if conn.IsClosed() {
|
|
t.Errorf("Handle closed the connection for %q", test.request)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Hijacking hands the connection to the handler, so Release must not close it.
|
|
// Ownership must not carry over: the next connection the exchange serves is
|
|
// the router's again and must be closed on Release.
|
|
func TestExchangeHijackOwnership(t *testing.T) {
|
|
var sm MuxSlice
|
|
var hijackErr error
|
|
sm.Handle("GET /", func(ex *Exchange) {
|
|
_, _, hijackErr = ex.HijackRaw(nil)
|
|
})
|
|
first := newConn("GET / HTTP/1.1\r\nHost: h\r\n\r\n")
|
|
first.Hangup()
|
|
exch := newExchange(t, first, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hijackErr != nil {
|
|
t.Fatal(hijackErr)
|
|
}
|
|
exch.Release()
|
|
if first.IsClosed() {
|
|
t.Error("hijacked connection must stay open after Release")
|
|
}
|
|
|
|
second := newConn("")
|
|
if !exch.Acquire(second) {
|
|
t.Fatal("released exchange must be acquirable")
|
|
}
|
|
exch.Release()
|
|
if !second.IsClosed() {
|
|
t.Error("connection must be closed on Release: hijack of a previous request must not carry over")
|
|
}
|
|
}
|
|
|
|
// Idle peer policy belongs to the connection: Handle keeps retrying an empty
|
|
// read until the conn itself reports failure, so a stalled peer ends the
|
|
// exchange through the conn's deadline instead of pinning the exchange.
|
|
func TestHandleIdlePeerEndsOnConnDeadline(t *testing.T) {
|
|
var sm MuxSlice
|
|
sm.Handle("/", func(ex *Exchange) { t.Error("handler must not run on partial request") })
|
|
conn := newConn("GET / HTTP") // Peer stalls mid request line, never hangs up.
|
|
conn.SetDeadline(time.Now().Add(10 * time.Millisecond))
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- Handle(exch, &sm, nopBackoff) }()
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Errorf("want connection deadline error, got %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Handle ignored the connection deadline")
|
|
}
|
|
}
|
|
|
|
// A body must never reach the wire without its header: if flushing the header
|
|
// fails, Write must report the failure and send nothing.
|
|
func TestExchangeWriteHeaderFlushFails(t *testing.T) {
|
|
const body = "body"
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*128), RequestBufferLim: 128})
|
|
conn.FailWrites(1) // Status line write fails, body write would succeed.
|
|
|
|
n, err := exch.WriteBody([]byte(body))
|
|
if err == nil {
|
|
t.Error("want error when header flush fails, got nil")
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("want 0 bytes written, got %d", n)
|
|
}
|
|
if got := conn.ViewWritten(); got != "" {
|
|
t.Errorf("want nothing on the wire, got %q", got)
|
|
}
|
|
// Writes after a failed header stay failed: the response is unrecoverable,
|
|
// a body without its header would corrupt the stream.
|
|
if _, err = exch.WriteBody([]byte(body)); err == nil {
|
|
t.Error("want error on write after failed header flush, got nil")
|
|
}
|
|
if got := conn.ViewWritten(); got != "" {
|
|
t.Errorf("want nothing on the wire, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExchangeSetHeaderInt(t *testing.T) {
|
|
for _, test := range []struct {
|
|
value int64
|
|
base int
|
|
want string // Header block emitted after the status line.
|
|
}{
|
|
{value: 1234, base: 10, want: "N:1234\r\n\r\n"},
|
|
{value: 0, base: 10, want: "N:0\r\n\r\n"},
|
|
{value: -42, base: 10, want: "N:-42\r\n\r\n"},
|
|
{value: 255, base: 16, want: "N:ff\r\n\r\n"},
|
|
{value: 9223372036854775807, base: 10, want: "N:9223372036854775807\r\n\r\n"},
|
|
{value: -9223372036854775808, base: 10, want: "N:-9223372036854775808\r\n\r\n"},
|
|
{value: 1, base: 2, want: "\r\n"}, // Below base 10, dropped.
|
|
{value: 1, base: 37, want: "\r\n"}, // Above base 36, dropped.
|
|
} {
|
|
name := strconv.FormatInt(test.value, 10) + "_base" + strconv.Itoa(test.base)
|
|
t.Run(name, func(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256})
|
|
exch.StageHeaderIntBase("N", test.value, test.base)
|
|
exch.WriteHeader(200)
|
|
got, _ := strings.CutPrefix(conn.ViewWritten(), "HTTP/1.1 200 OK\r\n")
|
|
if got != test.want {
|
|
t.Errorf("want %q, got %q", test.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// SetHeaderInt must format into the response buffer without allocating.
|
|
func TestExchangeSetHeaderIntNoAlloc(t *testing.T) {
|
|
exch := newExchange(t, newConn(""), ExchangeConfig{RawBuf: make([]byte, 2*256), RequestBufferLim: 256})
|
|
allocs := testing.AllocsPerRun(100, func() {
|
|
exch.StageHeaderIntBase("Content-Length", 1234567890, 10)
|
|
})
|
|
if allocs != 0 {
|
|
t.Errorf("SetHeaderInt allocated %v times, want 0", allocs)
|
|
}
|
|
}
|
|
|
|
// The Mux matches on the request path: a query string must not defeat routing.
|
|
func TestHandleMuxOnPath(t *testing.T) {
|
|
var sm MuxSlice
|
|
var gotPath, gotQuery string
|
|
sm.Handle("GET /search", func(ex *Exchange) {
|
|
gotPath = string(ex.RequestPath())
|
|
rawkey, rawval, rest := httpraw.NextQueryPair(ex.RequestQuery())
|
|
for rawkey != nil {
|
|
gotQuery += string(rawkey) + "=" + string(rawval) + ";"
|
|
rawkey, rawval, rest = httpraw.NextQueryPair(rest)
|
|
}
|
|
ex.WriteHeader(200)
|
|
})
|
|
conn := serve(t, "GET /search?q=go&n=1 HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
|
|
|
if gotPath != "/search" {
|
|
t.Errorf("want path %q, got %q", "/search", gotPath)
|
|
}
|
|
if gotQuery != "q=go;n=1;" {
|
|
t.Errorf("want query %q, got %q", "q=go;n=1;", gotQuery)
|
|
}
|
|
if got := conn.ViewWritten(); !strings.HasPrefix(got, "HTTP/1.1 200 OK\r\n") {
|
|
t.Errorf("want the handler to have run, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExchangeAppendQuery(t *testing.T) {
|
|
for _, test := range []struct {
|
|
uri string
|
|
key string
|
|
decoded bool
|
|
want string
|
|
wantPresent bool
|
|
}{
|
|
{uri: "/x?q=go", key: "q", want: "go", wantPresent: true},
|
|
{uri: "/x?q=go&n=1", key: "n", want: "1", wantPresent: true},
|
|
{uri: "/x?a=1&a=2", key: "a", want: "1", wantPresent: true}, // First match wins.
|
|
{uri: "/x?q=go", key: "nope", want: "", wantPresent: false}, // Absent.
|
|
{uri: "/x", key: "q", want: "", wantPresent: false}, // No query at all.
|
|
{uri: "/x?debug&q=go", key: "debug", want: "", wantPresent: true}, // Flag: present, no value.
|
|
{uri: "/x?q=", key: "q", want: "", wantPresent: true}, // Present, empty.
|
|
// Decoding is opt-in and applies to the value only.
|
|
{uri: "/x?q=hello%20world", key: "q", want: "hello%20world", wantPresent: true},
|
|
{uri: "/x?q=hello%20world", key: "q", decoded: true, want: "hello world", wantPresent: true},
|
|
{uri: "/x?q=a+b", key: "q", want: "a+b", wantPresent: true},
|
|
{uri: "/x?q=a+b", key: "q", decoded: true, want: "a b", wantPresent: true},
|
|
// Keys are matched decoded: "a b" cannot appear raw.
|
|
{uri: "/x?a%20b=c", key: "a b", want: "c", wantPresent: true},
|
|
{uri: "/x?a+b=c", key: "a b", want: "c", wantPresent: true},
|
|
// Malformed escapes: a bad key is skipped, a bad value is not returned.
|
|
{uri: "/x?%zz=1&q=go", key: "q", want: "go", wantPresent: true},
|
|
{uri: "/x?q=%zz", key: "q", decoded: true, want: "", wantPresent: false},
|
|
{uri: "/x?q=%zz", key: "q", want: "%zz", wantPresent: true}, // Undecoded, passed through.
|
|
} {
|
|
// The path is the same for every case: name them by what differs, and
|
|
// keep the '/' out so the name stays a single -run element.
|
|
name := strings.TrimPrefix(test.uri, "/x")
|
|
if name == "" {
|
|
name = "noquery"
|
|
}
|
|
name += "_" + test.key
|
|
if test.decoded {
|
|
name += "_decoded"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
var sm MuxSlice
|
|
var got string
|
|
var present bool
|
|
sm.Handle("/x", func(ex *Exchange) {
|
|
var value []byte
|
|
value, present = ex.RequestQueryAppend(nil, test.key, test.decoded)
|
|
got = string(value)
|
|
})
|
|
serve(t, "GET "+test.uri+" HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
|
|
|
if present != test.wantPresent {
|
|
t.Errorf("want present=%v, got %v", test.wantPresent, present)
|
|
}
|
|
if got != test.want {
|
|
t.Errorf("want %q, got %q", test.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// AppendQuery appends to dst, leaving what was already there untouched, and
|
|
// does not allocate when dst has the capacity.
|
|
func TestExchangeAppendQueryReusesBuffer(t *testing.T) {
|
|
var sm MuxSlice
|
|
var got string
|
|
var allocs float64
|
|
dst := make([]byte, 0, 64)
|
|
sm.Handle("/x", func(ex *Exchange) {
|
|
var value []byte
|
|
allocs = testing.AllocsPerRun(50, func() {
|
|
value, _ = ex.RequestQueryAppend(dst[:len("prefix:")], "q", true)
|
|
})
|
|
got = string(value) // Conversion allocates, keep it out of the measurement.
|
|
})
|
|
copy(dst[:cap(dst)], "prefix:")
|
|
serve(t, "GET /x?q=hello%20world HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
|
|
|
if got != "prefix:hello world" {
|
|
t.Errorf("want %q, got %q", "prefix:hello world", got)
|
|
}
|
|
if allocs != 0 {
|
|
t.Errorf("AppendQuery allocated %v times into a buffer with capacity, want 0", allocs)
|
|
}
|
|
}
|
|
|
|
// formString renders a form as "key=value" pairs joined by '|', a pair with no
|
|
// value shown as the bare key.
|
|
func formString(f *httpraw.Form) string {
|
|
var sb strings.Builder
|
|
for i := 0; i < f.Len(); i++ {
|
|
if i > 0 {
|
|
sb.WriteByte('|')
|
|
}
|
|
key, value := f.Pair(i)
|
|
sb.Write(key)
|
|
if value != nil {
|
|
sb.WriteByte('=')
|
|
sb.Write(value)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
const formType = "Content-Type: application/x-www-form-urlencoded\r\n"
|
|
|
|
// formPair is one key/value pair of a form body. flag sends the key bare, with
|
|
// no '=', which parses back as a nil value: distinct from a present but empty
|
|
// one, unlike http.FormValue.
|
|
type formPair struct {
|
|
key, value string
|
|
flag bool
|
|
}
|
|
|
|
// appendForm renders pairs as a urlencoded body, joining them with '&'.
|
|
func appendForm(dst []byte, pairs []formPair) []byte {
|
|
for i, pair := range pairs {
|
|
if i > 0 {
|
|
dst = append(dst, '&')
|
|
}
|
|
dst = append(dst, pair.key...)
|
|
if !pair.flag {
|
|
dst = append(dst, '=')
|
|
dst = append(dst, pair.value...)
|
|
}
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func TestExchangeRequestParseForm(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
formVals []formPair
|
|
// wantVals defaults to formVals: set it only where what comes back out
|
|
// differs from what went in, as decoding makes it.
|
|
wantVals []formPair
|
|
target string // Request target, defaults to "/f".
|
|
contentType string // Media type, defaults to application/x-www-form-urlencoded.
|
|
noContentType bool // Send no Content-Type field at all.
|
|
noContentLength bool // Send no Content-Length field: no body at all, RFC 9112 6.3.
|
|
extraHeaders string // Header fields sent verbatim, each CRLF terminated.
|
|
callDecode bool
|
|
bufsize int // Defaults to 64.
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "pairs",
|
|
formVals: []formPair{{key: "a", value: "1"}, {key: "b", value: "2"}, {key: "c", value: "3"}},
|
|
}, {
|
|
name: "long value",
|
|
formVals: []formPair{{key: "a", value: ""}, {key: "k", value: strings.Repeat("k", 20)}},
|
|
}, {
|
|
// A flag and an empty value stay distinguishable, unlike http.FormValue.
|
|
name: "flag and empty",
|
|
formVals: []formPair{{key: "a", flag: true}, {key: "b", value: ""}},
|
|
}, {
|
|
// Decoding is the caller's call: untouched without it.
|
|
name: "left encoded",
|
|
formVals: []formPair{{key: "n", value: "a%20b"}},
|
|
}, {
|
|
// Decode reaches both keys and values.
|
|
name: "decoded",
|
|
formVals: []formPair{{key: "a+b", value: "c%20d"}, {key: "e", value: "f%2B"}},
|
|
wantVals: []formPair{{key: "a b", value: "c d"}, {key: "e", value: "f+"}},
|
|
callDecode: true,
|
|
}, {
|
|
name: "media type parameters",
|
|
formVals: []formPair{{key: "a", value: "1"}},
|
|
contentType: "application/x-www-form-urlencoded; charset=utf-8",
|
|
}, {
|
|
// Only the body is parsed: the query string is not folded in.
|
|
name: "query not folded",
|
|
formVals: []formPair{{key: "a", value: "1"}},
|
|
target: "/f?q=go",
|
|
}, {
|
|
name: "no content length",
|
|
noContentLength: true,
|
|
}, {
|
|
name: "wrong media type",
|
|
formVals: []formPair{{key: "a", value: "1"}},
|
|
contentType: "text/plain",
|
|
wantErr: errNotFormEncoded,
|
|
}, {
|
|
// An absent field is not a wrong one: no media type is no body, the
|
|
// same answer "no content length" gets above. Only a type that is
|
|
// present and not form encoded is an error.
|
|
name: "no media type",
|
|
formVals: []formPair{{key: "a", value: "1"}},
|
|
noContentType: true,
|
|
wantVals: []formPair{},
|
|
}, {
|
|
// The coding is refused on the field alone, so the body stays off.
|
|
name: "chunked",
|
|
noContentLength: true,
|
|
extraHeaders: "Transfer-Encoding: chunked\r\n",
|
|
wantErr: errUnsupportedTransferCoding,
|
|
}, {
|
|
// The form bounds itself now, so an oversized body is the form
|
|
// refusing to grow rather than a short buffer handed in.
|
|
name: "body larger than buffer",
|
|
formVals: []formPair{{key: "a", value: "1"}, {key: "b", value: "2"}, {key: "c", value: "3"}},
|
|
bufsize: 4,
|
|
wantErr: httpraw.ErrBufferExhausted,
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
bufSize := test.bufsize
|
|
if bufSize == 0 {
|
|
bufSize = 64
|
|
}
|
|
target := test.target
|
|
if target == "" {
|
|
target = "/f"
|
|
}
|
|
contentType := test.contentType
|
|
if contentType == "" {
|
|
contentType = "application/x-www-form-urlencoded"
|
|
}
|
|
wantVals := test.wantVals
|
|
if wantVals == nil {
|
|
wantVals = test.formVals
|
|
}
|
|
body := appendForm(nil, test.formVals)
|
|
|
|
var builder strings.Builder
|
|
builder.WriteString("POST ")
|
|
builder.WriteString(target)
|
|
builder.WriteString(" HTTP/1.1\r\nHost: h\r\n")
|
|
if !test.noContentType {
|
|
builder.WriteString("Content-Type: ")
|
|
builder.WriteString(contentType)
|
|
builder.WriteString("\r\n")
|
|
}
|
|
builder.WriteString(test.extraHeaders)
|
|
if !test.noContentLength {
|
|
builder.WriteString("Content-Length: ")
|
|
builder.WriteString(strconv.Itoa(len(body)))
|
|
builder.WriteString("\r\n")
|
|
}
|
|
builder.WriteString("\r\n")
|
|
builder.Write(body)
|
|
|
|
// The form owns the memory: bufSize bounds it here, growth off so an
|
|
// oversized body is reported rather than allocated for.
|
|
var form httpraw.Form
|
|
form.Reset(make([]byte, 0, bufSize), defaultKVCap)
|
|
form.EnableBufferGrowth(false)
|
|
var gotErr error
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("/f", func(exch *Exchange) {
|
|
gotErr = exch.RequestParseForm(&form, false, false)
|
|
if gotErr == nil && test.callDecode {
|
|
gotErr = form.Decode()
|
|
}
|
|
})
|
|
serve(t, builder.String(), &sm)
|
|
|
|
if gotErr != test.wantErr {
|
|
t.Fatalf("want error %v, got %v", test.wantErr, gotErr)
|
|
} else if test.wantErr != nil {
|
|
return // Nothing is promised about the form on failure.
|
|
}
|
|
if form.Len() != len(wantVals) {
|
|
t.Fatalf("want %d pairs parsed, got %d: %q", len(wantVals), form.Len(), formString(&form))
|
|
}
|
|
for i, want := range wantVals {
|
|
key, value := form.Pair(i)
|
|
if b2s(key) != want.key {
|
|
t.Errorf("pair %d: want key %q, got %q", i, want.key, key)
|
|
}
|
|
switch {
|
|
case want.flag && value != nil:
|
|
t.Errorf("pair %d: want no value, got %q", i, value)
|
|
case !want.flag && value == nil:
|
|
t.Errorf("pair %d: want value %q, got no value", i, want.value)
|
|
case !want.flag && b2s(value) != want.value:
|
|
t.Errorf("pair %d: want value %q, got %q", i, want.value, value)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A body arriving after the header, in its own segment, must still be parsed whole.
|
|
func TestExchangeRequestParseFormSplit(t *testing.T) {
|
|
conn := newConn("POST /f HTTP/1.1\r\nHost: h\r\n" + formType + "Content-Length: 11\r\n\r\na=1&")
|
|
conn.AddSegment("b=2&c=3")
|
|
conn.Hangup()
|
|
var form httpraw.Form
|
|
var gotErr error
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("/f", func(exch *Exchange) {
|
|
gotErr = exch.RequestParseForm(&form, false, false)
|
|
})
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gotErr != nil {
|
|
t.Fatal(gotErr)
|
|
}
|
|
if got := formString(&form); got != "a=1|b=2|c=3" {
|
|
t.Errorf("want %q, got %q", "a=1|b=2|c=3", got)
|
|
}
|
|
}
|
|
|
|
// Decode is the caller's call, and it must reach both keys and values.
|
|
func TestExchangeRequestParseFormDecode(t *testing.T) {
|
|
var form httpraw.Form
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("/f", func(exch *Exchange) {
|
|
if err := exch.RequestParseForm(&form, false, false); err != nil {
|
|
t.Error(err)
|
|
} else if err = form.Decode(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
serve(t, "POST /f HTTP/1.1\r\nHost: h\r\n"+formType+"Content-Length: 16\r\n\r\na+b=c%20d&e=f%2B", &sm)
|
|
if got := formString(&form); got != "a b=c d|e=f+" {
|
|
t.Errorf("want %q, got %q", "a b=c d|e=f+", got)
|
|
}
|
|
}
|
|
|
|
// partBuffer is a sink that keeps a part's content in memory and records that
|
|
// [Exchange.ReadMultiparts] closed it.
|
|
type partBuffer struct {
|
|
content []byte
|
|
closed bool
|
|
}
|
|
|
|
func (p *partBuffer) Write(b []byte) (int, error) {
|
|
if p.closed {
|
|
return 0, errors.New("write to closed part sink")
|
|
}
|
|
p.content = append(p.content, b...)
|
|
return len(b), nil
|
|
}
|
|
|
|
func (p *partBuffer) Close() error { p.closed = true; return nil }
|
|
|
|
// multipartPart is one part of a multipart/form-data body. discard makes the
|
|
// sink factory refuse it, exercising [Exchange.ReadMultiparts]' discard path.
|
|
type multipartPart struct {
|
|
name, filename, content string
|
|
discard bool
|
|
}
|
|
|
|
// appendMultipart renders parts as a multipart/form-data body delimited by
|
|
// boundary, closed off with the terminating delimiter.
|
|
func appendMultipart(dst []byte, boundary string, parts []multipartPart) []byte {
|
|
for _, part := range parts {
|
|
dst = append(dst, "--"+boundary+"\r\n"...)
|
|
dst = append(dst, `Content-Disposition: form-data; name="`+part.name+`"`...)
|
|
if part.filename != "" {
|
|
dst = append(dst, `; filename="`+part.filename+`"`...)
|
|
}
|
|
dst = append(dst, "\r\n\r\n"...)
|
|
dst = append(dst, part.content...)
|
|
dst = append(dst, "\r\n"...)
|
|
}
|
|
return append(dst, "--"+boundary+"--\r\n"...)
|
|
}
|
|
|
|
// serveMultipart serves request to a handler that streams its multipart body
|
|
// with [Exchange.ReadMultiparts] over a buffer of bufSize bytes. Bytes past
|
|
// each offset in segmentAt are delivered on later reads, so the parser must
|
|
// compact and read more to see them. discard names the parts whose sink is
|
|
// refused.
|
|
func serveMultipart(t *testing.T, request string, bufSize int, discard []string, segmentAt []int) ([]MultipartSink, error) {
|
|
t.Helper()
|
|
prev := 0
|
|
for _, off := range segmentAt {
|
|
if off < prev || off > len(request) {
|
|
t.Fatalf("segment offset %d out of order or past the %d byte request", off, len(request))
|
|
}
|
|
prev = off
|
|
}
|
|
conn := newConn(request[:firstOr(segmentAt, len(request))])
|
|
for i, off := range segmentAt {
|
|
end := len(request)
|
|
if i+1 < len(segmentAt) {
|
|
end = segmentAt[i+1]
|
|
}
|
|
conn.AddSegment(request[off:end])
|
|
}
|
|
conn.Hangup()
|
|
var parts []MultipartSink
|
|
var gotErr error
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("/f", func(exch *Exchange) {
|
|
newSink := func(hdr *httpraw.MultipartHeader) io.WriteCloser {
|
|
if slices.Contains(discard, string(hdr.Name)) {
|
|
return nil // Discard this part's content.
|
|
}
|
|
return new(partBuffer)
|
|
}
|
|
parts, gotErr = exch.ReadMultiparts(parts, make([]byte, bufSize), newSink)
|
|
})
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return parts, gotErr
|
|
}
|
|
|
|
func firstOr(s []int, or int) int {
|
|
if len(s) == 0 {
|
|
return or
|
|
}
|
|
return s[0]
|
|
}
|
|
|
|
// checkParts asserts the part header and streamed content of every sink, that a
|
|
// discarded part has no sink at all, and that no sink was left open, which would
|
|
// hide a part that never ended.
|
|
func checkParts(t *testing.T, got []MultipartSink, want []multipartPart) {
|
|
t.Helper()
|
|
if len(got) != len(want) {
|
|
t.Fatalf("want %d parts, got %d: %q", len(want), len(got), partsString(t, got))
|
|
}
|
|
for i, want := range want {
|
|
part := &got[i]
|
|
if string(part.Header.Name) != want.name {
|
|
t.Errorf("part %d: want name %q, got %q", i, want.name, part.Header.Name)
|
|
}
|
|
if string(part.Header.Filename) != want.filename {
|
|
t.Errorf("part %d: want filename %q, got %q", i, want.filename, part.Header.Filename)
|
|
}
|
|
if want.discard {
|
|
if part.Sink != nil {
|
|
t.Errorf("part %d: want no sink for a discarded part, got one", i)
|
|
}
|
|
continue
|
|
}
|
|
if part.Sink == nil {
|
|
t.Errorf("part %d: want content %q, got no sink", i, want.content)
|
|
continue
|
|
}
|
|
sink := part.Sink.(*partBuffer)
|
|
if !sink.closed {
|
|
t.Errorf("part %d (%q): sink left open", i, want.name)
|
|
}
|
|
if string(sink.content) != want.content {
|
|
t.Errorf("part %d (%q): want content %q, got %q", i, want.name, want.content, sink.content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// partsString renders parts as "name=content" joined by '|', a file part shown
|
|
// as "name(filename)=content" and a discarded one as "name=<nil>". Fails the
|
|
// test if a sink was left open, which would hide a part that never ended.
|
|
func partsString(t *testing.T, parts []MultipartSink) string {
|
|
t.Helper()
|
|
var sb strings.Builder
|
|
for i := range parts {
|
|
if i > 0 {
|
|
sb.WriteByte('|')
|
|
}
|
|
part := &parts[i]
|
|
sb.Write(part.Header.Name)
|
|
if len(part.Header.Filename) > 0 {
|
|
sb.WriteByte('(')
|
|
sb.Write(part.Header.Filename)
|
|
sb.WriteByte(')')
|
|
}
|
|
sb.WriteByte('=')
|
|
if part.Sink == nil {
|
|
sb.WriteString("<nil>")
|
|
continue
|
|
}
|
|
sink := part.Sink.(*partBuffer)
|
|
if !sink.closed {
|
|
t.Errorf("part %q: sink left open", part.Header.Name)
|
|
}
|
|
sb.Write(sink.content)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// mpTeaser is content that teases the parser with delimiter prefixes that never
|
|
// complete, so a compaction that fails to hold the tail back drops part of it.
|
|
var mpTeaser = strings.Repeat("\r\n--xy", 16) + strings.Repeat("A", 100) + "\r\n--xyy"
|
|
|
|
func TestExchangeReadMultiparts(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
boundary string // Defaults to "xyz".
|
|
parts []multipartPart // The body sent.
|
|
// wantParts defaults to parts: set it only where what comes back out
|
|
// differs from what went in. A wantErr case reports no parts at all.
|
|
wantParts []multipartPart
|
|
// segmentAt are offsets into the body where a later read begins. The
|
|
// request header always arrives in the first read.
|
|
segmentAt []int
|
|
bufsize int // Defaults to 128.
|
|
wantErr error
|
|
}{
|
|
{
|
|
// Names, filenames and content of every part, over a body split so
|
|
// that a part straddles two reads and the parser must compact and
|
|
// read more. The boundary opens with dashes of its own, which the
|
|
// delimiter's leading "--" must not be confused with.
|
|
name: "parts and files",
|
|
boundary: "--xyz",
|
|
parts: []multipartPart{
|
|
{name: "caption", content: "hi there"},
|
|
{name: "photo", filename: "beach.png", content: "\x89PNG\r\n\x00"},
|
|
},
|
|
segmentAt: []int{89}, // Inside the second part's header.
|
|
}, {
|
|
// A part longer than the buffer must come out whole: every
|
|
// compaction has to keep the tail NextBody held back, or content
|
|
// that looks like the start of a delimiter is dropped. The header's
|
|
// Name must survive those reads too.
|
|
name: "part larger than buffer",
|
|
parts: []multipartPart{{name: "blob", content: mpTeaser}},
|
|
segmentAt: []int{0, 30}, // Header alone, then 30 bytes of body.
|
|
bufsize: 64,
|
|
}, {
|
|
// A nil sink discards a part's content without losing its place in
|
|
// the body: the parts around it must still arrive whole.
|
|
name: "discards part",
|
|
parts: []multipartPart{
|
|
{name: "keep", content: "kept"},
|
|
{name: "huge", filename: "big.bin", content: strings.Repeat("Z", 200), discard: true},
|
|
{name: "also", content: "kept too"},
|
|
},
|
|
bufsize: 96,
|
|
}, {
|
|
// A part header that does not fit the buffer cannot be completed by
|
|
// reading more, so the caller is told instead of spinning.
|
|
name: "header larger than buffer",
|
|
parts: []multipartPart{{name: strings.Repeat("n", 64), content: "v"}},
|
|
bufsize: 32,
|
|
wantErr: lneto.ErrShortBuffer,
|
|
}, {
|
|
// A buffer too small to ever outgrow a delimiter is a caller error,
|
|
// refused before any of the body is read.
|
|
name: "buffer unusable",
|
|
parts: []multipartPart{{name: "a", content: "v"}},
|
|
bufsize: len("\r\n--xyz"),
|
|
wantErr: lneto.ErrInvalidConfig,
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
boundary := test.boundary
|
|
if boundary == "" {
|
|
boundary = "xyz"
|
|
}
|
|
bufSize := test.bufsize
|
|
if bufSize == 0 {
|
|
bufSize = 128
|
|
}
|
|
wantParts := test.wantParts
|
|
if wantParts == nil {
|
|
wantParts = test.parts
|
|
}
|
|
var discard []string
|
|
for _, part := range test.parts {
|
|
if part.discard {
|
|
discard = append(discard, part.name)
|
|
}
|
|
}
|
|
head := "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: multipart/form-data; boundary=" + boundary + "\r\n\r\n"
|
|
body := appendMultipart(nil, boundary, test.parts)
|
|
segmentAt := make([]int, len(test.segmentAt))
|
|
for i, off := range test.segmentAt {
|
|
if off < 0 || off >= len(body) {
|
|
t.Fatalf("segment offset %d is not inside the %d byte body", off, len(body))
|
|
}
|
|
segmentAt[i] = len(head) + off
|
|
}
|
|
|
|
parts, err := serveMultipart(t, head+string(body), bufSize, discard, segmentAt)
|
|
if err != test.wantErr {
|
|
t.Fatalf("want error %v, got %v", test.wantErr, err)
|
|
}
|
|
if test.wantErr != nil {
|
|
// Nothing parsed is promised on failure, and a part reported
|
|
// for a header that never parsed is a part the caller cannot
|
|
// act on.
|
|
if len(parts) != 0 {
|
|
t.Errorf("want no parts reported, got %d: %q", len(parts), partsString(t, parts))
|
|
}
|
|
return
|
|
}
|
|
checkParts(t, parts, wantParts)
|
|
})
|
|
}
|
|
}
|
|
|
|
// A request that is not multipart, or whose boundary is missing, must be refused.
|
|
func TestExchangeRequestParseMultipartRejects(t *testing.T) {
|
|
for _, test := range []struct {
|
|
contentType string
|
|
wantErr bool
|
|
}{
|
|
{contentType: "multipart/form-data; boundary=xyz"},
|
|
{contentType: "application/x-www-form-urlencoded", wantErr: true},
|
|
{contentType: "multipart/form-data", wantErr: true}, // Boundary is required.
|
|
{contentType: "", wantErr: true},
|
|
} {
|
|
name := test.contentType
|
|
if name == "" {
|
|
name = "no content type"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
var gotErr error
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("/f", func(exch *Exchange) {
|
|
_, gotErr = exch.RequestMultipart()
|
|
})
|
|
request := "POST /f HTTP/1.1\r\nHost: h\r\n"
|
|
if test.contentType != "" {
|
|
request += "Content-Type: " + test.contentType + "\r\n"
|
|
}
|
|
serve(t, request+"\r\n", &sm)
|
|
if (gotErr != nil) != test.wantErr {
|
|
t.Errorf("want error %v, got %v", test.wantErr, gotErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A request from a browser carries around twenty header fields. Serving one
|
|
// must not depend on how many fields the parser happens to have room for: the
|
|
// exchange's buffer is the memory the caller granted, and the field table comes
|
|
// out of it.
|
|
func TestHandleBrowserSizedRequest(t *testing.T) {
|
|
const wantMode = "navigate"
|
|
request := "GET /echo HTTP/1.1\r\nHost: lneto.test\r\n" +
|
|
"User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36\r\n" +
|
|
"Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8\r\n" +
|
|
"Accept-Language: en-US,en;q=0.5\r\nAccept-Encoding: gzip, deflate, br\r\n" +
|
|
"Upgrade-Insecure-Requests: 1\r\nSec-Fetch-Dest: document\r\nSec-Fetch-Site: none\r\n" +
|
|
"Sec-Ch-Ua: \"Chromium\";v=\"120\"\r\nCache-Control: max-age=0\r\nDnt: 1\r\n" +
|
|
"Referer: https://lneto.test/index.html\r\nCookie: session=abcdef0123456789; theme=dark\r\n" +
|
|
"X-Trace: 0123456789abcdef\r\nX-Client: bench\r\nX-Seq: 42\r\nX-Tag: alpha\r\n" +
|
|
"X-Nonce: cafebabe\r\nX-Mode: " + wantMode + "\r\n\r\n"
|
|
|
|
var gotMode string
|
|
var fields int
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("GET /echo", func(exch *Exchange) {
|
|
gotMode = string(exch.RequestHeader("X-Mode"))
|
|
exch.RequestHeaderV1Raw().ForEach(func(key, value []byte) bool {
|
|
fields++
|
|
return true
|
|
})
|
|
})
|
|
conn := newConn(request)
|
|
conn.Hangup()
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*8192), RequestBufferLim: 8192})
|
|
if err := Handle(exch, &sm, nopBackoff); err != nil {
|
|
t.Fatalf("serving a browser sized request: %s", err)
|
|
}
|
|
if gotMode != wantMode {
|
|
t.Errorf("last header field read back as %q, want %q", gotMode, wantMode)
|
|
}
|
|
const sent = 19
|
|
if fields < sent {
|
|
t.Errorf("handler saw %d header fields, request carried %d", fields, sent)
|
|
}
|
|
}
|
|
|
|
// A request larger than the exchange has room for must be answered, not
|
|
// dropped: the peer learns its request was too large instead of seeing the
|
|
// connection go away. Either bound may be the one it ran into, and both are
|
|
// reported to the caller.
|
|
func TestHandleRequestTooLargeAnswers431(t *testing.T) {
|
|
var request strings.Builder
|
|
request.WriteString("GET /echo HTTP/1.1\r\nHost: lneto.test\r\n")
|
|
for i := range 512 {
|
|
request.WriteByte('H')
|
|
request.WriteString(strconv.Itoa(i))
|
|
request.WriteString(":v\r\n")
|
|
}
|
|
request.WriteString("\r\n")
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
cfg ExchangeConfig
|
|
wantErr error
|
|
}{
|
|
{
|
|
// Room for every byte of the block, but not for its fields. The
|
|
// field table only bounds the request when growth is refused:
|
|
// otherwise it is the size the parser starts from, not a limit.
|
|
name: "more fields than the table holds",
|
|
cfg: ExchangeConfig{
|
|
RawBuf: make([]byte, 16*1024), RequestBufferLim: 8 * 1024,
|
|
NumHeaderKVCap: 16, NoRequestBufferGrowth: true,
|
|
},
|
|
wantErr: httpraw.ErrHeaderTooMany,
|
|
},
|
|
{
|
|
// Room for the fields, but not for the bytes they arrive in.
|
|
name: "more bytes than the buffer holds",
|
|
cfg: ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024, NumHeaderKVCap: 1024, NoRequestBufferGrowth: true},
|
|
wantErr: httpraw.ErrBufferExhausted,
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var served bool
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("GET /echo", func(exch *Exchange) { served = true })
|
|
conn := newConn(request.String())
|
|
conn.Hangup()
|
|
exch := newExchange(t, conn, test.cfg)
|
|
err := Handle(exch, &sm, nopBackoff)
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("want %v reported to the caller, got %v", test.wantErr, err)
|
|
}
|
|
if served {
|
|
t.Fatal("handler ran on a request the parser could not hold")
|
|
}
|
|
got := conn.ViewWritten()
|
|
if !strings.HasPrefix(got, "HTTP/1.1 431 ") {
|
|
t.Errorf("want a 431 answer, got %q", firstLine(got))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func firstLine(s string) string {
|
|
if before, _, ok := strings.Cut(s, "\r\n"); ok {
|
|
return before
|
|
}
|
|
return s
|
|
}
|
|
|
|
// A body that already arrived alongside the request header must be handed over
|
|
// without touching the connection again. A peer that sent a whole request and
|
|
// is waiting for its answer sends nothing more, so a read for bytes already in
|
|
// hand blocks until the connection's deadline, or forever without one.
|
|
func TestExchangeReadBodyDoesNotReadPastWhatArrived(t *testing.T) {
|
|
const body = "message body"
|
|
dst := make([]byte, 64) // Deliberately larger than the body.
|
|
var got string
|
|
var readErr error
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("POST /", func(ex *Exchange) {
|
|
n, err := ex.ReadBody(dst)
|
|
got, readErr = string(dst[:n]), err
|
|
ex.WriteHeader(200)
|
|
})
|
|
// The peer is still there, waiting to be answered: a read for bytes it is
|
|
// not going to send blocks, exactly as it does on a socket.
|
|
conn := &blockingConn{request: "POST / HTTP/1.1\r\nHost: h\r\nContent-Length: 12\r\n\r\n" + body}
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 2*1024), RequestBufferLim: 1024})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
Handle(exch, &sm, nopBackoff)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("ReadBody blocked waiting for a body that had already arrived")
|
|
}
|
|
if readErr != nil {
|
|
t.Fatal(readErr)
|
|
}
|
|
if got != body {
|
|
t.Errorf("want body %q, got %q", body, got)
|
|
}
|
|
}
|
|
|
|
// blockingConn delivers a request and then blocks on reads, the way a peer
|
|
// awaiting its answer does. Writes are discarded.
|
|
type blockingConn struct {
|
|
request string
|
|
read int
|
|
blocked chan struct{}
|
|
}
|
|
|
|
func (c *blockingConn) Read(b []byte) (int, error) {
|
|
if c.read >= len(c.request) {
|
|
if c.blocked == nil {
|
|
c.blocked = make(chan struct{})
|
|
}
|
|
<-c.blocked // Nothing more is coming, and nothing unblocks this.
|
|
return 0, io.EOF
|
|
}
|
|
n := copy(b, c.request[c.read:])
|
|
c.read += n
|
|
return n, nil
|
|
}
|
|
|
|
func (c *blockingConn) Write(b []byte) (int, error) { return len(b), nil }
|
|
func (c *blockingConn) Close() error { return nil }
|
|
|
|
// Field names are case insensitive, RFC 9110 5.1, and HTTP/2 mandates lowercase
|
|
// ones, so an h2-to-h1 proxy sends "content-type". The field must be found
|
|
// whatever the case, or form parsing refuses a body it should accept.
|
|
func TestExchangeRequestContentTypeFolded(t *testing.T) {
|
|
for _, name := range []string{"Content-Type", "content-type", "CONTENT-TYPE", "cOnTeNt-TyPe"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
const formType = "application/x-www-form-urlencoded"
|
|
var gotType string
|
|
var gotErr error
|
|
var form httpraw.Form
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("/f", func(exch *Exchange) {
|
|
gotType = string(exch.RequestContentType())
|
|
gotErr = exch.RequestParseForm(&form, false, false)
|
|
})
|
|
serve(t, "POST /f HTTP/1.1\r\nHost: h\r\n"+name+": "+formType+"\r\nContent-Length: 3\r\n\r\na=1", &sm)
|
|
|
|
if gotType != formType {
|
|
t.Fatalf("want Content-Type %q, got %q", formType, gotType)
|
|
}
|
|
if gotErr != nil {
|
|
t.Fatalf("want form parsed, got %v", gotErr)
|
|
}
|
|
if key, value := form.Pair(0); b2s(key) != "a" || b2s(value) != "1" {
|
|
t.Errorf("want a=1, got %q=%q", key, value)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The Transfer-Encoding guard stops chunk framing being read as form data. A
|
|
// lowercase field name must not slip past it: with a Content-Length alongside,
|
|
// the chunk sizes and their CRLFs land inside the parsed pairs.
|
|
func TestExchangeRequestParseFormFoldedTransferEncoding(t *testing.T) {
|
|
for _, name := range []string{"Transfer-Encoding", "transfer-encoding", "TRANSFER-ENCODING"} {
|
|
t.Run(name, func(t *testing.T) {
|
|
const body = "3\r\na=1\r\n0\r\n\r\n"
|
|
var gotErr error
|
|
var form httpraw.Form
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("/f", func(exch *Exchange) {
|
|
gotErr = exch.RequestParseForm(&form, false, false)
|
|
})
|
|
serve(t, "POST /f HTTP/1.1\r\nHost: h\r\nContent-Type: application/x-www-form-urlencoded\r\n"+
|
|
name+": chunked\r\nContent-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm)
|
|
|
|
if gotErr != errUnsupportedTransferCoding {
|
|
t.Fatalf("want %v, got %v with %d pairs %q", errUnsupportedTransferCoding, gotErr, form.Len(), formString(&form))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Respond replaces the stage/stage/stage/write boilerplate every handler paid,
|
|
// deriving Content-Length from the body so it cannot disagree with what is sent.
|
|
func TestExchangeRespond(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
code int
|
|
contentType string
|
|
body string
|
|
want string
|
|
}{
|
|
{
|
|
name: "html", code: 200, contentType: "text/html", body: "<h1>hi</h1>",
|
|
want: "HTTP/1.1 200 OK\r\nContent-Type:text/html\r\nContent-Length:11\r\nConnection:close\r\n\r\n<h1>hi</h1>",
|
|
},
|
|
{
|
|
name: "empty body still declares zero length", code: 200, contentType: "text/plain", body: "",
|
|
want: "HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\nContent-Length:0\r\nConnection:close\r\n\r\n",
|
|
},
|
|
{
|
|
name: "no content type staged when empty", code: 204, contentType: "", body: "",
|
|
want: "HTTP/1.1 204 No Content\r\nContent-Length:0\r\nConnection:close\r\n\r\n",
|
|
},
|
|
{
|
|
name: "error code carries a body", code: 500, contentType: "text/plain", body: "boom",
|
|
want: "HTTP/1.1 500 Internal Server Error\r\nContent-Type:text/plain\r\nContent-Length:4\r\nConnection:close\r\n\r\nboom",
|
|
},
|
|
} {
|
|
t.Run(test.name+"/bytes", func(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256})
|
|
if err := exch.Respond(test.code, test.contentType, []byte(test.body)); err != nil {
|
|
t.Fatalf("Respond: %s", err)
|
|
}
|
|
if got := conn.ViewWritten(); got != test.want {
|
|
t.Errorf("want %q, got %q", test.want, got)
|
|
}
|
|
})
|
|
t.Run(test.name+"/string", func(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 512), RequestBufferLim: 256})
|
|
if err := exch.RespondString(test.code, test.contentType, test.body); err != nil {
|
|
t.Fatalf("RespondString: %s", err)
|
|
}
|
|
if got := conn.ViewWritten(); got != test.want {
|
|
t.Errorf("want %q, got %q", test.want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A response that does not fit must be reported, not shipped truncated: the
|
|
// whole point of folding the boilerplate into one call.
|
|
func TestExchangeRespondReportsOverflow(t *testing.T) {
|
|
conn := newConn("")
|
|
exch := newExchange(t, conn, ExchangeConfig{RawBuf: make([]byte, 64), RequestBufferLim: 32})
|
|
err := exch.Respond(200, strings.Repeat("t", 200), []byte("body"))
|
|
if err == nil {
|
|
t.Fatal("want an error for a response header that cannot fit")
|
|
}
|
|
if got := conn.ViewWritten(); got != "" {
|
|
t.Errorf("nothing must reach the wire, got %q", got)
|
|
}
|
|
if exch.ResponseError() == nil {
|
|
t.Error("want the failure recorded on the exchange too")
|
|
}
|
|
}
|
|
|
|
// Query and body are read into one form buffer and parsed together, so both
|
|
// sources are present at once and read order decides which value a key resolves
|
|
// to. A key carried by both keeps both pairs, in wire order.
|
|
func TestExchangeRequestParseFormFoldsQuery(t *testing.T) {
|
|
const body = "cnt=body&only=b"
|
|
const target = "/f?cnt=query&page=2"
|
|
for _, test := range []struct {
|
|
name string
|
|
parseURL, prioritizeURL bool
|
|
wantCnt string
|
|
wantPage string
|
|
wantRendered string
|
|
}{
|
|
{
|
|
name: "body only", parseURL: false,
|
|
wantCnt: "body", wantPage: "", wantRendered: "cnt=body|only=b",
|
|
},
|
|
{
|
|
name: "query first wins", parseURL: true, prioritizeURL: true,
|
|
wantCnt: "query", wantPage: "2", wantRendered: "cnt=query|page=2|cnt=body|only=b",
|
|
},
|
|
{
|
|
name: "body first wins", parseURL: true, prioritizeURL: false,
|
|
wantCnt: "body", wantPage: "2", wantRendered: "cnt=body|only=b|cnt=query|page=2",
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var form httpraw.Form
|
|
var gotErr error
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("POST /f", func(exch *Exchange) {
|
|
gotErr = exch.RequestParseForm(&form, test.parseURL, test.prioritizeURL)
|
|
})
|
|
serve(t, "POST "+target+" HTTP/1.1\r\nHost: h\r\n"+
|
|
"Content-Type: application/x-www-form-urlencoded\r\n"+
|
|
"Content-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm)
|
|
if gotErr != nil {
|
|
t.Fatalf("RequestParseForm: %s", gotErr)
|
|
}
|
|
if got := string(form.Get("cnt")); got != test.wantCnt {
|
|
t.Errorf("want cnt=%q, got %q", test.wantCnt, got)
|
|
}
|
|
if got := string(form.Get("page")); got != test.wantPage {
|
|
t.Errorf("want page=%q, got %q", test.wantPage, got)
|
|
}
|
|
if got := formString(&form); got != test.wantRendered {
|
|
t.Errorf("want pairs %q, got %q", test.wantRendered, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A GET with a query and no body must fold the query alone: no Content-Type
|
|
// means no body to parse, which is not an error.
|
|
func TestExchangeRequestParseFormQueryWithoutBody(t *testing.T) {
|
|
var form httpraw.Form
|
|
var gotErr error
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("GET /f", func(exch *Exchange) {
|
|
gotErr = exch.RequestParseForm(&form, true, true)
|
|
})
|
|
serve(t, "GET /f?a=1&b=2 HTTP/1.1\r\nHost: h\r\n\r\n", &sm)
|
|
if gotErr != nil {
|
|
t.Fatalf("want the query parsed with no body, got %s", gotErr)
|
|
}
|
|
if got := formString(&form); got != "a=1|b=2" {
|
|
t.Errorf("want a=1|b=2, got %q", got)
|
|
}
|
|
}
|
|
|
|
// The separator must not merge the two sources into one pair: without it the
|
|
// last query pair and the first body pair run together.
|
|
func TestExchangeRequestParseFormSourcesNotMerged(t *testing.T) {
|
|
var form httpraw.Form
|
|
var sm MuxSlice
|
|
sm.Reset(1)
|
|
sm.Handle("POST /f", func(exch *Exchange) {
|
|
if err := exch.RequestParseForm(&form, true, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
const body = "second=2"
|
|
serve(t, "POST /f?first=1 HTTP/1.1\r\nHost: h\r\n"+
|
|
"Content-Type: application/x-www-form-urlencoded\r\n"+
|
|
"Content-Length: "+strconv.Itoa(len(body))+"\r\n\r\n"+body, &sm)
|
|
if got := formString(&form); got != "first=1|second=2" {
|
|
t.Errorf("want first=1|second=2, got %q", got)
|
|
}
|
|
}
|