Files
zeekay c10cd537a6 the deployment answers what is asked of the process
The rule and the counters moved onto the Base the process serves from; the client
KEY did not, and that turned the previous fix into a cross-tenant denial of
service. checkRateLimit keys on RealIP, and RealIP asked e.App which proxy
headers to believe — the tenant's Base by then, whose TrustedProxy is empty and
can never be anything else, since only a superuser writes settings and a tenant's
Base has none. Behind an ingress every authenticated request fell back to the
socket peer, which is the ingress for all of them: one bucket for the estate, so
one tenant spending its budget locked out every other. Measured: alpha spends a
3-per-minute budget and beta, arriving from another address, is refused 3 of 3.

Rather than teach the limiter a second way to find that Base, core.RequestEvent
now names it — Deployment() — and everything that answers a question about the
process rather than about a tenant's data reads it there. RealIP, the rate limit
and its counters, the batch limits, and the activity log.

The log had the same bug and worse consequences. logRequest read e.App, so an
authenticated request wrote its audit row into the tenant's own file and the
operator's log held only traffic that never authenticated. It also wrote
RequestURI() whole, and a key arrives in ?key= on a request that carries no
Authorization header, so a live secret key sat in _logs in plaintext for
Logs.MaxDays, was served by GET /v1/logs, and went into every backup. The query
is redacted for the parameters this deployment reads a credential out of. And a
keyed request sets no e.Auth, so every action a service key took was attributed
to nobody; the subject is what both doors publish, so a subject with no record is
exactly a key.

Batch read Enabled off the tenant too, and a freshly opened Base answers false,
so an operator who turned batching on had it silently off for everyone with a
token.

RemoteIP reports no address where it cannot read one, instead of the zero Addr,
whose expanded spelling is the literal string "invalid IP". A ZAP-forwarded
request carries no socket peer, so the whole leg was counted as that one client.
2026-08-11 16:08:08 -07:00

960 lines
24 KiB
Go

package router_test
import (
"bytes"
"crypto/tls"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/hanzoai/base/tools/router"
)
type unwrapTester struct {
http.ResponseWriter
}
func (ut unwrapTester) Unwrap() http.ResponseWriter {
return ut.ResponseWriter
}
func TestEventWritten(t *testing.T) {
t.Parallel()
res1 := httptest.NewRecorder()
res2 := httptest.NewRecorder()
res2.Write([]byte("test"))
res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4.Write([]byte("test"))
scenarios := []struct {
name string
response http.ResponseWriter
expected bool
}{
{
name: "non-written non-WriteTracker",
response: res1,
expected: false,
},
{
name: "written non-WriteTracker",
response: res2,
expected: false,
},
{
name: "non-written WriteTracker",
response: res3,
expected: false,
},
{
name: "written WriteTracker",
response: res4,
expected: true,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
event := router.Event{
Response: s.response,
}
result := event.Written()
if result != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestEventStatus(t *testing.T) {
t.Parallel()
res1 := httptest.NewRecorder()
res2 := httptest.NewRecorder()
res2.WriteHeader(123)
res3 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4 := &router.ResponseWriter{ResponseWriter: unwrapTester{httptest.NewRecorder()}}
res4.WriteHeader(123)
scenarios := []struct {
name string
response http.ResponseWriter
expected int
}{
{
name: "non-written non-StatusTracker",
response: res1,
expected: 0,
},
{
name: "written non-StatusTracker",
response: res2,
expected: 0,
},
{
name: "non-written StatusTracker",
response: res3,
expected: 0,
},
{
name: "written StatusTracker",
response: res4,
expected: 123,
},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
event := router.Event{
Response: s.response,
}
result := event.Status()
if result != s.expected {
t.Fatalf("Expected %d, got %d", s.expected, result)
}
})
}
}
func TestEventIsTLS(t *testing.T) {
t.Parallel()
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
event := router.Event{Request: req}
// without TLS
if event.IsTLS() {
t.Fatalf("Expected IsTLS false")
}
// dummy TLS state
req.TLS = new(tls.ConnectionState)
// with TLS
if !event.IsTLS() {
t.Fatalf("Expected IsTLS true")
}
}
func TestEventSetCookie(t *testing.T) {
t.Parallel()
event := router.Event{
Response: httptest.NewRecorder(),
}
cookie := event.Response.Header().Get("set-cookie")
if cookie != "" {
t.Fatalf("Expected empty cookie string, got %q", cookie)
}
event.SetCookie(&http.Cookie{Name: "test", Value: "a"})
expected := "test=a"
cookie = event.Response.Header().Get("set-cookie")
if cookie != expected {
t.Fatalf("Expected cookie %q, got %q", expected, cookie)
}
}
func TestEventRemoteIP(t *testing.T) {
t.Parallel()
scenarios := []struct {
remoteAddr string
expected string
}{
{"", ""},
{"1.2.3.4", ""}, // no port, so nothing to split — an address it cannot read is no address
{"1.2.3.4:8090", "1.2.3.4"},
{"[0000:0000:0000:0000:0000:0000:0000:0002]:80", "0000:0000:0000:0000:0000:0000:0000:0002"},
{"[::2]:80", "0000:0000:0000:0000:0000:0000:0000:0002"}, // should always return the expanded version
}
for _, s := range scenarios {
t.Run(s.remoteAddr, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
req.RemoteAddr = s.remoteAddr
event := router.Event{Request: req}
ip := event.RemoteIP()
if ip != s.expected {
t.Fatalf("Expected IP %q, got %q", s.expected, ip)
}
})
}
}
func TestFindUploadedFiles(t *testing.T) {
scenarios := []struct {
filename string
expectedPattern string
}{
{"ab.png", `^ab\w{10}_\w{10}\.png$`},
{"test", `^test_\w{10}\.txt$`},
{"a b c d!@$.j!@$pg", `^a_b_c_d_\w{10}\.jpg$`},
{strings.Repeat("a", 150), `^a{100}_\w{10}\.txt$`},
}
for _, s := range scenarios {
t.Run(s.filename, func(t *testing.T) {
// create multipart form file body
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
w, err := mp.CreateFormFile("test", s.filename)
if err != nil {
t.Fatal(err)
}
w.Write([]byte("test"))
mp.Close()
// ---
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Add("Content-Type", mp.FormDataContentType())
event := router.Event{Request: req}
result, err := event.FindUploadedFiles("test")
if err != nil {
t.Fatal(err)
}
if len(result) != 1 {
t.Fatalf("Expected 1 file, got %d", len(result))
}
if result[0].Size != 4 {
t.Fatalf("Expected the file size to be 4 bytes, got %d", result[0].Size)
}
pattern, err := regexp.Compile(s.expectedPattern)
if err != nil {
t.Fatalf("Invalid filename pattern %q: %v", s.expectedPattern, err)
}
if !pattern.MatchString(result[0].Name) {
t.Fatalf("Expected filename to match %s, got filename %s", s.expectedPattern, result[0].Name)
}
})
}
}
func TestFindUploadedFilesMissing(t *testing.T) {
body := new(bytes.Buffer)
mp := multipart.NewWriter(body)
mp.Close()
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Add("Content-Type", mp.FormDataContentType())
event := router.Event{Request: req}
result, err := event.FindUploadedFiles("test")
if err == nil {
t.Error("Expected error, got nil")
}
if result != nil {
t.Errorf("Expected result to be nil, got %v", result)
}
}
func TestEventSetGet(t *testing.T) {
event := router.Event{}
// get before any set (ensures that doesn't panic)
if v := event.Get("test"); v != nil {
t.Fatalf("Expected nil value, got %v", v)
}
event.Set("a", 123)
event.Set("b", 456)
scenarios := []struct {
key string
expected any
}{
{"", nil},
{"missing", nil},
{"a", 123},
{"b", 456},
}
for i, s := range scenarios {
t.Run(fmt.Sprintf("%d_%s", i, s.key), func(t *testing.T) {
result := event.Get(s.key)
if result != s.expected {
t.Fatalf("Expected %v, got %v", s.expected, result)
}
})
}
}
func TestEventSetAllGetAll(t *testing.T) {
data := map[string]any{
"a": 123,
"b": 456,
}
rawData, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
event := router.Event{}
event.SetAll(data)
// modify the data to ensure that the map was shallow copied
data["c"] = 789
result := event.GetAll()
rawResult, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if len(rawResult) == 0 || !bytes.Equal(rawData, rawResult) {
t.Fatalf("Expected\n%v\ngot\n%v", rawData, rawResult)
}
}
func TestEventString(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
status: 234,
headers: nil,
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/plain; charset=utf-8"},
expectedBody: "test",
},
{
name: "with explicit content-type",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.String(s.status, s.body)
})
}
}
func TestEventHTML(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
status: 234,
headers: nil,
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/html; charset=utf-8"},
expectedBody: "test",
},
{
name: "with explicit content-type",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.HTML(s.status, s.body)
})
}
}
func TestEventJSON(t *testing.T) {
body := map[string]any{"a": 123, "b": 456, "c": "test"}
expectedPickedBody := `{"a":123,"c":"test"}` + "\n"
expectedFullBody := `{"a":123,"b":456,"c":"test"}` + "\n"
scenarios := []testResponseWriteScenario[any]{
{
name: "no explicit content-type",
status: 200,
headers: nil,
body: body,
expectedStatus: 200,
expectedHeaders: map[string]string{"content-type": "application/json"},
expectedBody: expectedPickedBody,
},
{
name: "with explicit content-type (200)",
status: 200,
headers: map[string]string{"content-type": "application/test"},
body: body,
expectedStatus: 200,
expectedHeaders: map[string]string{"content-type": "application/test"},
expectedBody: expectedPickedBody,
},
{
name: "with explicit content-type (400)", // no fields picker
status: 400,
headers: map[string]string{"content-type": "application/test"},
body: body,
expectedStatus: 400,
expectedHeaders: map[string]string{"content-type": "application/test"},
expectedBody: expectedFullBody,
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
e.Request.URL.RawQuery = "fields=a,c" // ensures that the picker is invoked
return e.JSON(s.status, s.body)
})
}
}
func TestEventXML(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "no explicit content-type",
status: 234,
headers: nil,
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "application/xml; charset=utf-8"},
expectedBody: xml.Header + "<string>test</string>",
},
{
name: "with explicit content-type",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: xml.Header + "<string>test</string>",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.XML(s.status, s.body)
})
}
}
func TestEventStream(t *testing.T) {
scenarios := []testResponseWriteScenario[string]{
{
name: "stream",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: "test",
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.Stream(s.status, s.headers["content-type"], strings.NewReader(s.body))
})
}
}
func TestEventBlob(t *testing.T) {
scenarios := []testResponseWriteScenario[[]byte]{
{
name: "blob",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: []byte("test"),
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "test",
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.Blob(s.status, s.headers["content-type"], s.body)
})
}
}
func TestEventNoContent(t *testing.T) {
s := testResponseWriteScenario[any]{
name: "no content",
status: 234,
headers: map[string]string{"content-type": "text/test"},
body: nil,
expectedStatus: 234,
expectedHeaders: map[string]string{"content-type": "text/test"},
expectedBody: "",
}
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.NoContent(s.status)
})
}
func TestEventFlush(t *testing.T) {
rec := httptest.NewRecorder()
event := &router.Event{
Response: unwrapTester{&router.ResponseWriter{ResponseWriter: rec}},
}
event.Response.Write([]byte("test"))
event.Flush()
if !rec.Flushed {
t.Fatal("Expected response to be flushed")
}
}
func TestEventRedirect(t *testing.T) {
scenarios := []testResponseWriteScenario[any]{
{
name: "non-30x status",
status: 200,
expectedStatus: 200,
expectedError: router.ErrInvalidRedirectStatusCode,
},
{
name: "30x status",
status: 302,
headers: map[string]string{"location": "test"}, // should be overwritten with the argument
expectedStatus: 302,
expectedHeaders: map[string]string{"location": "example"},
},
}
for _, s := range scenarios {
testEventResponseWrite(t, s, func(e *router.Event) error {
return e.Redirect(s.status, "example")
})
}
}
func TestEventFileFS(t *testing.T) {
// stub test files
// ---
dir, err := os.MkdirTemp("", "EventFileFS")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
err = os.WriteFile(filepath.Join(dir, "index.html"), []byte("index"), 0644)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "test.txt"), []byte("test"), 0644)
if err != nil {
t.Fatal(err)
}
// create sub directory with an index.html file inside it
err = os.MkdirAll(filepath.Join(dir, "sub1"), os.ModePerm)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "sub1", "index.html"), []byte("sub1 index"), 0644)
if err != nil {
t.Fatal(err)
}
err = os.MkdirAll(filepath.Join(dir, "sub2"), os.ModePerm)
if err != nil {
t.Fatal(err)
}
err = os.WriteFile(filepath.Join(dir, "sub2", "test.txt"), []byte("sub2 test"), 0644)
if err != nil {
t.Fatal(err)
}
// ---
scenarios := []struct {
name string
path string
expected string
}{
{"missing file", "", ""},
{"root with no explicit file", "", ""},
{"root with explicit file", "test.txt", "test"},
{"sub dir with no explicit file", "sub1", "sub1 index"},
{"sub dir with no explicit file (no index.html)", "sub2", ""},
{"sub dir explicit file", "sub2/test.txt", "sub2 test"},
}
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
event := &router.Event{
Request: req,
Response: rec,
}
err = event.FileFS(os.DirFS(dir), s.path)
hasErr := err != nil
expectErr := s.expected == ""
if hasErr != expectErr {
t.Fatalf("Expected hasErr %v, got %v (%v)", expectErr, hasErr, err)
}
result := rec.Result()
raw, err := io.ReadAll(result.Body)
result.Body.Close()
if err != nil {
t.Fatal(err)
}
if string(raw) != s.expected {
t.Fatalf("Expected body\n%s\ngot\n%s", s.expected, raw)
}
// ensure that the proper file headers are added
// (aka. http.ServeContent is invoked)
length, _ := strconv.Atoi(result.Header.Get("content-length"))
if length != len(s.expected) {
t.Fatalf("Expected Content-Length %d, got %d", len(s.expected), length)
}
})
}
}
func TestEventError(t *testing.T) {
err := new(router.Event).Error(123, "message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":123}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventBadRequestError(t *testing.T) {
err := new(router.Event).BadRequestError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":400}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventNotFoundError(t *testing.T) {
err := new(router.Event).NotFoundError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":404}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventForbiddenError(t *testing.T) {
err := new(router.Event).ForbiddenError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":403}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventUnauthorizedError(t *testing.T) {
err := new(router.Event).UnauthorizedError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":401}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventTooManyRequestsError(t *testing.T) {
err := new(router.Event).TooManyRequestsError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":429}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventInternalServerError(t *testing.T) {
err := new(router.Event).InternalServerError("message_test", map[string]any{"a": validation.Required, "b": "test"})
result, _ := json.Marshal(err)
expected := `{"data":{"a":{"code":"validation_invalid_value","message":"Invalid value."},"b":{"code":"validation_invalid_value","message":"Invalid value."}},"message":"Message_test.","status":500}`
if string(result) != expected {
t.Errorf("Expected\n%s\ngot\n%s", expected, result)
}
}
func TestEventBindBody(t *testing.T) {
type testDstStruct struct {
A int `json:"a" xml:"a" form:"a"`
B int `json:"b" xml:"b" form:"b"`
C string `json:"c" xml:"c" form:"c"`
}
emptyDst := `{"a":0,"b":0,"c":""}`
queryDst := `a=123&b=-456&c=test`
xmlDst := `
<?xml version="1.0" encoding="UTF-8" ?>
<root>
<a>123</a>
<b>-456</b>
<c>test</c>
</root>
`
jsonDst := `{"a":123,"b":-456,"c":"test"}`
// multipart
mpBody := &bytes.Buffer{}
mpWriter := multipart.NewWriter(mpBody)
mpWriter.WriteField("@jsonPayload", `{"a":123}`)
mpWriter.WriteField("b", "-456")
mpWriter.WriteField("c", "test")
if err := mpWriter.Close(); err != nil {
t.Fatal(err)
}
scenarios := []struct {
contentType string
body io.Reader
expectDst string
expectError bool
}{
{
contentType: "",
body: strings.NewReader(jsonDst),
expectDst: emptyDst,
expectError: true,
},
{
contentType: "application/rtf", // unsupported
body: strings.NewReader(jsonDst),
expectDst: emptyDst,
expectError: true,
},
// empty body
{
contentType: "application/json;charset=emptybody",
body: strings.NewReader(""),
expectDst: emptyDst,
},
// json
{
contentType: "application/json",
body: strings.NewReader(jsonDst),
expectDst: jsonDst,
},
{
contentType: "application/json;charset=abc",
body: strings.NewReader(jsonDst),
expectDst: jsonDst,
},
// xml
{
contentType: "text/xml",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
{
contentType: "text/xml;charset=abc",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
{
contentType: "application/xml",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
{
contentType: "application/xml;charset=abc",
body: strings.NewReader(xmlDst),
expectDst: jsonDst,
},
// x-www-form-urlencoded
{
contentType: "application/x-www-form-urlencoded",
body: strings.NewReader(queryDst),
expectDst: jsonDst,
},
{
contentType: "application/x-www-form-urlencoded;charset=abc",
body: strings.NewReader(queryDst),
expectDst: jsonDst,
},
// multipart
{
contentType: mpWriter.FormDataContentType(),
body: mpBody,
expectDst: jsonDst,
},
}
for _, s := range scenarios {
t.Run(s.contentType, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "/", s.body)
if err != nil {
t.Fatal(err)
}
req.Header.Add("content-type", s.contentType)
event := &router.Event{Request: req}
dst := testDstStruct{}
err = event.BindBody(&dst)
hasErr := err != nil
if hasErr != s.expectError {
t.Fatalf("Expected hasErr %v, got %v (%v)", s.expectError, hasErr, err)
}
dstRaw, err := json.Marshal(dst)
if err != nil {
t.Fatal(err)
}
if string(dstRaw) != s.expectDst {
t.Fatalf("Expected dst\n%s\ngot\n%s", s.expectDst, dstRaw)
}
})
}
}
// -------------------------------------------------------------------
type testResponseWriteScenario[T any] struct {
name string
status int
headers map[string]string
body T
expectedStatus int
expectedHeaders map[string]string
expectedBody string
expectedError error
}
func testEventResponseWrite[T any](
t *testing.T,
scenario testResponseWriteScenario[T],
writeFunc func(e *router.Event) error,
) {
t.Run(scenario.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
event := &router.Event{
Request: req,
Response: &router.ResponseWriter{ResponseWriter: rec},
}
for k, v := range scenario.headers {
event.Response.Header().Add(k, v)
}
err = writeFunc(event)
if (scenario.expectedError != nil || err != nil) && !errors.Is(err, scenario.expectedError) {
t.Fatalf("Expected error %v, got %v", scenario.expectedError, err)
}
result := rec.Result()
if result.StatusCode != scenario.expectedStatus {
t.Fatalf("Expected status code %d, got %d", scenario.expectedStatus, result.StatusCode)
}
resultBody, err := io.ReadAll(result.Body)
result.Body.Close()
if err != nil {
t.Fatalf("Failed to read response body: %v", err)
}
resultBody, err = json.Marshal(string(resultBody))
if err != nil {
t.Fatal(err)
}
expectedBody, err := json.Marshal(scenario.expectedBody)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(resultBody, expectedBody) {
t.Fatalf("Expected body\n%s\ngot\n%s", expectedBody, resultBody)
}
for k, ev := range scenario.expectedHeaders {
if v := result.Header.Get(k); v != ev {
t.Fatalf("Expected %q header to be %q, got %q", k, ev, v)
}
}
})
}