more tests, run go generate

This commit is contained in:
Patricio Whittingslow
2026-07-25 20:43:26 -03:00
parent e0dd19416c
commit 5b83b18da0
14 changed files with 549 additions and 50 deletions
+281
View File
@@ -0,0 +1,281 @@
package httphi
import (
"strings"
"testing"
"time"
"github.com/soypat/lneto"
)
func nopBackoff(consecutiveBackoffs uint) time.Duration { return lneto.BackoffFlagNop }
// newExchange returns an Exchange acquired on conn, ready to serve a request.
func newExchange(t *testing.T, conn conn, bufferSize int, normalizeKeys bool) *Exchange {
t.Helper()
exch := new(Exchange)
exch.Configure(make([]byte, 2*bufferSize), bufferSize, normalizeKeys)
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, bufferSize, false)
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) {
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"},
} {
exch := newExchange(t, newConn(""), 128, false)
exch.WriteHeader(test.code)
if got := exch.rw.(*rwconn).ViewWritten(); got != test.want {
t.Errorf("code %d: want %q, got %q", test.code, test.want, got)
}
}
}
// Status line is written once: a second WriteHeader must not reach the wire.
func TestExchangeWriteHeaderOnce(t *testing.T) {
conn := newConn("")
exch := newExchange(t, conn, 128, false)
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) {
const body = "hello"
conn := newConn("")
exch := newExchange(t, conn, 128, false)
n, err := exch.Write([]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, 128, test.normalize)
for _, kv := range test.set {
if !exch.SetHeader(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, bufferSize, false)
if exch.SetHeader("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 sliceMux
sm.Handle(test.wantURI, func(ex *Exchange) {
gotMethod = string(ex.RequestMethod())
gotURI = string(ex.RequestURI())
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 sliceMux
sm.Handle("/", func(ex *Exchange) { handled = true })
conn := newConn(test.request)
conn.Hangup()
exch := newExchange(t, conn, 1024, false)
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")
}
})
}
}
// No registered handler must yield 404, not an empty response.
func TestHandleNoHandler(t *testing.T) {
var sm sliceMux
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 sliceMux
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.
func TestExchangeReadBody(t *testing.T) {
const body = "message body"
var got string
var readErr error
var sm sliceMux
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)
}
}
+16 -2
View File
@@ -1,6 +1,7 @@
package httphi
import (
"errors"
"io"
"log/slog"
"strconv"
@@ -16,6 +17,8 @@ import (
//go:generate stringer -type Method,status -linecomment -output stringers.go
var errNoRequestProto = errors.New("httphi: request line with no HTTP version")
type conn = io.ReadWriteCloser
// Router hosts concurrent safe data.
@@ -160,6 +163,13 @@ func Handle(exch *Exchange, mux Mux, backoff lneto.BackoffStrategy) error {
exch.respRemains = reqhdr.BufferReceived() - parsed
exch.respHeaderOff = uint16(parsed)
exch.respHeaderLen = 0
if len(reqhdr.Protocol()) == 0 {
// Request line with no HTTP version is a HTTP/0.9 simple-request, which
// httpraw tolerates. It is not a valid HTTP/1.1 request-line, RFC 9112 3.
exch.WriteHeader(int(StatusBadRequest))
exch.rw.Close()
return errNoRequestProto
}
// Mux URI.
uri := reqhdr.RequestURI()
meth := reqhdr.Method()
@@ -272,9 +282,13 @@ func (r *Router) info(msg string, attrs ...slog.Attr) {
internal.LogAttrs(r.log, slog.LevelInfo, msg, attrs...)
}
// maxStatusLine bounds the response status line: "HTTP/1.1 " + 3 digit code +
// " " + longest [StatusText] + CRLF.
const maxStatusLine = len("HTTP/1.1 ") + 3 + 1 + len("Network Authentication Required") + 2
type Exchange struct {
used atomic.Bool
respTopBuf [32]byte
respTopBuf [maxStatusLine]byte
respTopWritten uint8
rawbuf []byte
@@ -357,7 +371,7 @@ func (exch *Exchange) StageWriteStatus(code int) {
n += copy(exch.respTopBuf[n:], text)
exch.respTopBuf[n] = '\r'
exch.respTopBuf[n+1] = '\n'
exch.respTopWritten = uint8(n)
exch.respTopWritten = uint8(n + 2)
}
func (exch *Exchange) WriteHeader(code int) {
+17 -1
View File
@@ -16,11 +16,16 @@ import (
// rwconn is a in-memory conn. The router handles connections on another
// goroutine so every field is guarded; onClose lets tests await the handler.
//
// A drained rwconn reads (0,nil) as a live socket with no data pending would,
// so a partial request can be completed with AddReadable mid-handling. Tests
// that need the peer to hang up call Hangup.
type rwconn struct {
mu sync.Mutex
readable bytes.Buffer
written bytes.Buffer
closed bool
hangup bool
onClose chan struct{}
deadline time.Time
}
@@ -33,6 +38,14 @@ func newConn(request string) *rwconn {
return r
}
// Hangup makes reads past the pending data return [io.EOF], as a peer that
// closed its side of the connection would.
func (r *rwconn) Hangup() {
r.mu.Lock()
defer r.mu.Unlock()
r.hangup = true
}
func (r *rwconn) Close() error {
r.mu.Lock()
defer r.mu.Unlock()
@@ -63,7 +76,10 @@ func (r *rwconn) Read(b []byte) (int, error) {
} else if r.deadlineExceeded() {
return 0, context.DeadlineExceeded
} else if r.readable.Len() == 0 {
return 0, io.EOF
if r.hangup {
return 0, io.EOF
}
return 0, nil // No data pending, handler backs off and retries.
}
return r.readable.Read(b)
}
+175
View File
@@ -0,0 +1,175 @@
// Code generated by "stringer -type Method,status -linecomment -output stringers.go"; DO NOT EDIT.
package httphi
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[MethUndefined-0]
_ = x[MethGet-1]
_ = x[MethHead-2]
_ = x[MethPost-3]
_ = x[MethPut-4]
_ = x[MethPatch-5]
_ = x[MethDelete-6]
_ = x[MethConnect-7]
_ = x[MethOptions-8]
_ = x[MethTrace-9]
_ = x[MethUnknown-10]
}
const _Method_name = "undefinedGETHEADPOSTPUTPATCHDELETECONNECTOPTIONSTRACEunknown"
var _Method_index = [...]uint8{0, 9, 12, 16, 20, 23, 28, 34, 41, 48, 53, 60}
func (i Method) String() string {
idx := int(i) - 0
if i < 0 || idx >= len(_Method_index)-1 {
return "Method(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Method_name[_Method_index[idx]:_Method_index[idx+1]]
}
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[StatusContinue-100]
_ = x[StatusSwitchingProtocols-101]
_ = x[StatusProcessing-102]
_ = x[StatusEarlyHints-103]
_ = x[StatusOK-200]
_ = x[StatusCreated-201]
_ = x[StatusAccepted-202]
_ = x[StatusNonAuthoritativeInfo-203]
_ = x[StatusNoContent-204]
_ = x[StatusResetContent-205]
_ = x[StatusPartialContent-206]
_ = x[StatusMultiStatus-207]
_ = x[StatusAlreadyReported-208]
_ = x[StatusIMUsed-226]
_ = x[StatusMultipleChoices-300]
_ = x[StatusMovedPermanently-301]
_ = x[StatusFound-302]
_ = x[StatusSeeOther-303]
_ = x[StatusNotModified-304]
_ = x[StatusUseProxy-305]
_ = x[StatusTemporaryRedirect-307]
_ = x[StatusPermanentRedirect-308]
_ = x[StatusBadRequest-400]
_ = x[StatusUnauthorized-401]
_ = x[StatusPaymentRequired-402]
_ = x[StatusForbidden-403]
_ = x[StatusNotFound-404]
_ = x[StatusMethodNotAllowed-405]
_ = x[StatusNotAcceptable-406]
_ = x[StatusProxyAuthRequired-407]
_ = x[StatusRequestTimeout-408]
_ = x[StatusConflict-409]
_ = x[StatusGone-410]
_ = x[StatusLengthRequired-411]
_ = x[StatusPreconditionFailed-412]
_ = x[StatusRequestEntityTooLarge-413]
_ = x[StatusRequestURITooLong-414]
_ = x[StatusUnsupportedMediaType-415]
_ = x[StatusRequestedRangeNotSatisfiable-416]
_ = x[StatusExpectationFailed-417]
_ = x[StatusTeapot-418]
_ = x[StatusMisdirectedRequest-421]
_ = x[StatusUnprocessableEntity-422]
_ = x[StatusLocked-423]
_ = x[StatusFailedDependency-424]
_ = x[StatusTooEarly-425]
_ = x[StatusUpgradeRequired-426]
_ = x[StatusPreconditionRequired-428]
_ = x[StatusTooManyRequests-429]
_ = x[StatusRequestHeaderFieldsTooLarge-431]
_ = x[StatusUnavailableForLegalReasons-451]
_ = x[StatusInternalServerError-500]
_ = x[StatusNotImplemented-501]
_ = x[StatusBadGateway-502]
_ = x[StatusServiceUnavailable-503]
_ = x[StatusGatewayTimeout-504]
_ = x[StatusHTTPVersionNotSupported-505]
_ = x[StatusVariantAlsoNegotiates-506]
_ = x[StatusInsufficientStorage-507]
_ = x[StatusLoopDetected-508]
_ = x[StatusNotExtended-510]
_ = x[StatusNetworkAuthenticationRequired-511]
}
const _status_name = "ContinueSwitching ProtocolsProcessingEarly HintsOKCreatedAcceptedNon-Authoritative InformationNo ContentReset ContentPartial ContentMulti-StatusAlready ReportedIM UsedMultiple ChoicesMoved PermanentlyFoundSee OtherNot ModifiedUse ProxyTemporary RedirectPermanent RedirectBad RequestUnauthorizedPayment RequiredForbiddenNot FoundMethod Not AllowedNot AcceptableProxy Authentication RequiredRequest TimeoutConflictGoneLength RequiredPrecondition FailedRequest Entity Too LargeRequest URI Too LongUnsupported Media TypeRequested Range Not SatisfiableExpectation FailedI'm a teapotMisdirected RequestUnprocessable EntityLockedFailed DependencyToo EarlyUpgrade RequiredPrecondition RequiredToo Many RequestsRequest Header Fields Too LargeUnavailable For Legal ReasonsInternal Server ErrorNot ImplementedBad GatewayService UnavailableGateway TimeoutHTTP Version Not SupportedVariant Also NegotiatesInsufficient StorageLoop DetectedNot ExtendedNetwork Authentication Required"
var _status_map = map[status]string{
100: _status_name[0:8],
101: _status_name[8:27],
102: _status_name[27:37],
103: _status_name[37:48],
200: _status_name[48:50],
201: _status_name[50:57],
202: _status_name[57:65],
203: _status_name[65:94],
204: _status_name[94:104],
205: _status_name[104:117],
206: _status_name[117:132],
207: _status_name[132:144],
208: _status_name[144:160],
226: _status_name[160:167],
300: _status_name[167:183],
301: _status_name[183:200],
302: _status_name[200:205],
303: _status_name[205:214],
304: _status_name[214:226],
305: _status_name[226:235],
307: _status_name[235:253],
308: _status_name[253:271],
400: _status_name[271:282],
401: _status_name[282:294],
402: _status_name[294:310],
403: _status_name[310:319],
404: _status_name[319:328],
405: _status_name[328:346],
406: _status_name[346:360],
407: _status_name[360:389],
408: _status_name[389:404],
409: _status_name[404:412],
410: _status_name[412:416],
411: _status_name[416:431],
412: _status_name[431:450],
413: _status_name[450:474],
414: _status_name[474:494],
415: _status_name[494:516],
416: _status_name[516:547],
417: _status_name[547:565],
418: _status_name[565:577],
421: _status_name[577:596],
422: _status_name[596:616],
423: _status_name[616:622],
424: _status_name[622:639],
425: _status_name[639:648],
426: _status_name[648:664],
428: _status_name[664:685],
429: _status_name[685:702],
431: _status_name[702:733],
451: _status_name[733:762],
500: _status_name[762:783],
501: _status_name[783:798],
502: _status_name[798:809],
503: _status_name[809:828],
504: _status_name[828:843],
505: _status_name[843:869],
506: _status_name[869:892],
507: _status_name[892:912],
508: _status_name[912:925],
510: _status_name[925:937],
511: _status_name[937:968],
}
func (i status) String() string {
if str, ok := _status_map[i]; ok {
return str
}
return "status(" + strconv.FormatInt(int64(i), 10) + ")"
}