mirror of
https://github.com/hanzoai/base.git
synced 2026-10-21 03:41:50 +00:00
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.
960 lines
24 KiB
Go
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)
|
|
}
|
|
}
|
|
})
|
|
}
|