407 lines
13 KiB
Go
407 lines
13 KiB
Go
package transporttest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yuntuo.hk/k2safe/go-omni/app"
|
|
"git.yuntuo.hk/k2safe/go-omni/auth"
|
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
)
|
|
|
|
func application(t *testing.T, newRouter func() httpx.Router) {
|
|
t.Run("explicit application methods", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
mark := func(next httpx.Handler) httpx.Handler {
|
|
return func(c *httpx.Context) error {
|
|
c.SetResponseHeader("X-Middleware", "present")
|
|
return next(c)
|
|
}
|
|
}
|
|
a.Handle("HEAD", "/users", func(c *httpx.Context) error { return c.NoContent() }, mark)
|
|
group := a.Group("/api", mark)
|
|
group.Handle("OPTIONS", "/users", func(c *httpx.Context) error { return c.NoContent() })
|
|
group.Handle("REPORT", "/users", func(c *httpx.Context) error { return c.OK("report") })
|
|
for _, tc := range []struct {
|
|
method, path string
|
|
status int
|
|
}{
|
|
{"HEAD", "/users", 204}, {"OPTIONS", "/api/users", 204}, {"REPORT", "/api/users", 200},
|
|
} {
|
|
w := request(a, tc.method, tc.path, "")
|
|
if w.Code != tc.status || w.Header().Get("X-Middleware") != "present" || w.Header().Get("X-Request-ID") == "" {
|
|
t.Fatalf("%s bypassed the application chain: %d %v", tc.method, w.Code, w.Header())
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("middleware order and registration snapshot", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
var calls []string
|
|
mark := func(name string) httpx.Middleware {
|
|
return func(next httpx.Handler) httpx.Handler {
|
|
return func(c *httpx.Context) error {
|
|
calls = append(calls, name+" in")
|
|
err := next(c)
|
|
calls = append(calls, name+" out")
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
a.Use(mark("app"))
|
|
group := a.Group("/api", mark("group"))
|
|
nested := group.Group("/v1", mark("nested"))
|
|
nested.GET("/users/:id", func(c *httpx.Context) error {
|
|
calls = append(calls, "handler")
|
|
return c.OK(c.Param("id"))
|
|
}, mark("route"))
|
|
a.Use(mark("lateApp"))
|
|
group.Use(mark("lateGroup"))
|
|
request(a, "GET", "/api/v1/users/42", "")
|
|
want := "app in,group in,nested in,route in,handler,route out,nested out,group out,app out"
|
|
if got := strings.Join(calls, ","); got != want {
|
|
t.Fatalf("middleware order=%s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("binding and business context", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
var logs bytes.Buffer
|
|
logger, err := logx.New(logx.Config{Format: "json", Writer: &logs})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer logger.Close()
|
|
deadline := time.Now().Add(time.Minute)
|
|
a.POST("/users/:id", func(c *httpx.Context) error {
|
|
var uri struct {
|
|
ID int `uri:"id" binding:"min=1"`
|
|
}
|
|
var query struct {
|
|
PageSize int `form:"pageSize" binding:"min=1"`
|
|
}
|
|
var body struct {
|
|
Name string `json:"name" binding:"required"`
|
|
}
|
|
if err := c.BindURI(&uri); err != nil {
|
|
return err
|
|
}
|
|
if err := c.BindQuery(&query); err != nil {
|
|
return err
|
|
}
|
|
if err := c.BindJSON(&body); err != nil {
|
|
return err
|
|
}
|
|
// Business services consume standard context only.
|
|
result, err := businessResult(c.Context(), logger, deadline, uri.ID, query.PageSize, body.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return c.Created(result)
|
|
})
|
|
req := httptest.NewRequest("POST", "/users/42?pageSize=20", strings.NewReader(`{"name":"Ada"}`))
|
|
ctx, cancel := context.WithDeadline(context.WithValue(req.Context(), contextKey{}, "original"), deadline)
|
|
defer cancel()
|
|
req = req.WithContext(ctx)
|
|
req.Header.Set("X-Request-ID", "businessRequest")
|
|
w := httptest.NewRecorder()
|
|
a.ServeHTTP(w, req)
|
|
if w.Code != 201 || !strings.Contains(w.Body.String(), `"pageSize":20`) || !strings.Contains(w.Body.String(), `"requestId":"businessRequest"`) {
|
|
t.Fatalf("unexpected business response: %d %s", w.Code, w.Body)
|
|
}
|
|
if !strings.Contains(logs.String(), `"requestId":"businessRequest"`) {
|
|
t.Fatalf("business log lost request context: %s", &logs)
|
|
}
|
|
for _, tc := range []struct{ path, body, code string }{
|
|
{"/users/bad?pageSize=20", `{"name":"Ada"}`, "INVALID_PATH"},
|
|
{"/users/42?pageSize=bad", `{"name":"Ada"}`, "INVALID_QUERY"},
|
|
{"/users/42?pageSize=20", "{", "INVALID_BODY"},
|
|
{"/users/42?pageSize=20", "{}", "INVALID_BODY"},
|
|
} {
|
|
w := request(a, "POST", tc.path, tc.body)
|
|
assertEnvelope(t, w, 400, tc.code)
|
|
}
|
|
})
|
|
|
|
t.Run("authentication short circuit", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
calls := 0
|
|
provider := auth.StaticAuthenticator{
|
|
"admin": {UserID: "adminUser", Roles: []string{"admin"}},
|
|
"reader": {UserID: "readerUser"},
|
|
}
|
|
a.GET("/private", func(c *httpx.Context) error {
|
|
calls++
|
|
p, ok := auth.FromContext(c)
|
|
if !ok {
|
|
return errors.New("missing principal")
|
|
}
|
|
return c.OK(p.UserID)
|
|
}, auth.RequireAuth(provider), auth.RequireRole("admin"))
|
|
for _, tc := range []struct {
|
|
token string
|
|
status int
|
|
code string
|
|
}{
|
|
{"", 401, "UNAUTHENTICATED"}, {"invalid", 401, "UNAUTHENTICATED"},
|
|
{"reader", 403, "FORBIDDEN"}, {"admin", 200, "OK"},
|
|
} {
|
|
req := httptest.NewRequest("GET", "/private", nil)
|
|
if tc.token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+tc.token)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.ServeHTTP(w, req)
|
|
assertEnvelope(t, w, tc.status, tc.code)
|
|
}
|
|
if calls != 1 {
|
|
t.Fatalf("unauthorized requests reached business handler: %d calls", calls)
|
|
}
|
|
})
|
|
|
|
t.Run("error recovery and committed response", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
for _, tc := range []struct {
|
|
path string
|
|
handler httpx.Handler
|
|
status int
|
|
code string
|
|
}{
|
|
{"/notFound", func(c *httpx.Context) error {
|
|
return fmt.Errorf("wrapped: %w", apierrors.NotFound("USER_MISSING", "user missing"))
|
|
}, 404, "USER_MISSING"},
|
|
{"/internal", func(c *httpx.Context) error { return errors.New("private database password") }, 500, "INTERNAL_ERROR"},
|
|
{"/panic", func(c *httpx.Context) error { panic("private database password") }, 500, "INTERNAL_ERROR"},
|
|
{"/encode", func(c *httpx.Context) error { return c.OK(make(chan int)) }, 500, "INTERNAL_ERROR"},
|
|
{"/written", func(c *httpx.Context) error {
|
|
if err := c.Created("saved"); err != nil {
|
|
return err
|
|
}
|
|
return errors.New("private database password")
|
|
}, 201, "CREATED"},
|
|
{"/panicAfterWrite", func(c *httpx.Context) error { _ = c.Accepted("saved"); panic("private database password") }, 202, "ACCEPTED"},
|
|
{"/doubleWrite", func(c *httpx.Context) error { _ = c.OK("first"); return c.Created("second") }, 200, "OK"},
|
|
} {
|
|
a.GET(tc.path, tc.handler)
|
|
w := request(a, "GET", tc.path, "")
|
|
assertEnvelope(t, w, tc.status, tc.code)
|
|
if strings.Contains(w.Body.String(), "private") {
|
|
t.Fatalf("internal cause leaked: %s", w.Body)
|
|
}
|
|
}
|
|
a.DELETE("/deleted", func(c *httpx.Context) error {
|
|
_ = c.NoContent()
|
|
return errors.New("after response")
|
|
})
|
|
w := request(a, "DELETE", "/deleted", "")
|
|
if w.Code != 204 || w.Body.Len() != 0 {
|
|
t.Fatalf("204 response=%d %s", w.Code, w.Body)
|
|
}
|
|
})
|
|
|
|
t.Run("concurrent request isolation", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
a.GET("/users/:id", func(c *httpx.Context) error {
|
|
c.Set("local", c.Param("id"))
|
|
value, _ := c.Get("local")
|
|
return c.OK(value)
|
|
})
|
|
var workers sync.WaitGroup
|
|
for i := 0; i < 32; i++ {
|
|
workers.Add(1)
|
|
go func(i int) {
|
|
defer workers.Done()
|
|
id := fmt.Sprint(i)
|
|
req := httptest.NewRequest("GET", "/users/"+id, nil)
|
|
req.Header.Set("X-Request-ID", "request"+id)
|
|
w := httptest.NewRecorder()
|
|
a.ServeHTTP(w, req)
|
|
var body struct {
|
|
Data string
|
|
RequestID string `json:"requestId"`
|
|
}
|
|
err := json.Unmarshal(w.Body.Bytes(), &body)
|
|
if err != nil || body.Data != id || body.RequestID != "request"+id || w.Header().Get("X-Request-ID") != "request"+id {
|
|
t.Errorf("request %s leaked state: %s, error=%v", id, w.Body, err)
|
|
}
|
|
}(i)
|
|
}
|
|
workers.Wait()
|
|
})
|
|
|
|
t.Run("middleware panic", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
a.GET("/panic", func(c *httpx.Context) error {
|
|
t.Error("handler reached after middleware panic")
|
|
return nil
|
|
}, func(httpx.Handler) httpx.Handler {
|
|
return func(*httpx.Context) error { panic("private middleware state") }
|
|
})
|
|
assertEnvelope(t, request(a, "GET", "/panic", ""), 500, "INTERNAL_ERROR")
|
|
})
|
|
|
|
t.Run("write failure preserves committed response", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
a.GET("/write", func(c *httpx.Context) error { return c.Created("value") })
|
|
w := &failedWriter{ResponseRecorder: httptest.NewRecorder()}
|
|
a.ServeHTTP(w, httptest.NewRequest("GET", "/write", nil))
|
|
if w.Code != 201 || w.headers != 1 || w.writes != 1 {
|
|
t.Fatalf("error response appended after failed write: status=%d headers=%d writes=%d", w.Code, w.headers, w.writes)
|
|
}
|
|
})
|
|
|
|
t.Run("expired deadline reaches service", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
a.GET("/deadline", func(c *httpx.Context) error {
|
|
if !errors.Is(c.Context().Err(), context.DeadlineExceeded) {
|
|
t.Errorf("deadline lost: %v", c.Context().Err())
|
|
}
|
|
select {
|
|
case <-c.Context().Done():
|
|
default:
|
|
t.Error("expired request context was replaced")
|
|
}
|
|
return c.Context().Err()
|
|
})
|
|
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
|
defer cancel()
|
|
req := httptest.NewRequest("GET", "/deadline", nil).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
a.ServeHTTP(w, req)
|
|
assertEnvelope(t, w, 500, "INTERNAL_ERROR")
|
|
})
|
|
|
|
t.Run("client cancellation reaches service", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
started := make(chan struct{})
|
|
done := make(chan error, 1)
|
|
stop := make(chan struct{})
|
|
a.GET("/wait/:id", func(c *httpx.Context) error {
|
|
close(started)
|
|
err := waitService(c.Context(), stop)
|
|
done <- err
|
|
return err
|
|
})
|
|
server := httptest.NewServer(a)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer func() { cancel(); close(stop); server.Close() }()
|
|
clientDone := make(chan error, 1)
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", server.URL+"/wait/42", nil)
|
|
go func() {
|
|
resp, err := server.Client().Do(req)
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
clientDone <- err
|
|
}()
|
|
select {
|
|
case <-started:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("handler did not start")
|
|
}
|
|
cancel()
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("service cancellation=%v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("client cancellation did not reach service")
|
|
}
|
|
select {
|
|
case err := <-clientDone:
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("client cancellation=%v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("client did not return")
|
|
}
|
|
})
|
|
|
|
t.Run("HEAD over a real connection", func(t *testing.T) {
|
|
a := app.NewWithRouter(newRouter())
|
|
a.Any("/head", func(c *httpx.Context) error { return c.OK("body") })
|
|
server := httptest.NewServer(a)
|
|
defer server.Close()
|
|
req, _ := http.NewRequest("HEAD", server.URL+"/head", nil)
|
|
client := server.Client()
|
|
client.Timeout = 5 * time.Second
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil || resp.StatusCode != 200 || len(body) != 0 || resp.Header.Get("X-Request-ID") == "" {
|
|
t.Fatalf("HEAD status=%d body=%q error=%v", resp.StatusCode, body, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func businessResult(ctx context.Context, logger *logx.Logger, deadline time.Time, id, size int, name string) (any, error) {
|
|
got, ok := ctx.Deadline()
|
|
if !ok || !got.Equal(deadline) || ctx.Value(contextKey{}) != "original" || logx.RequestID(ctx) != "businessRequest" {
|
|
return nil, errors.New("service lost request context")
|
|
}
|
|
logger.InfoContext(ctx, "business.read", "userId", id)
|
|
return map[string]any{"userId": id, "pageSize": size, "name": name}, nil
|
|
}
|
|
|
|
func waitService(ctx context.Context, stop <-chan struct{}) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-stop:
|
|
return errors.New("test cleanup")
|
|
}
|
|
}
|
|
|
|
func request(handler http.Handler, method, path, body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
handler.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func assertEnvelope(t *testing.T, w *httptest.ResponseRecorder, status int, code string) {
|
|
t.Helper()
|
|
var body struct {
|
|
Code string
|
|
RequestID string `json:"requestId"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("invalid response JSON: %s: %v", w.Body, err)
|
|
}
|
|
if w.Code != status || body.Code != code || body.RequestID == "" || body.RequestID != w.Header().Get("X-Request-ID") {
|
|
t.Fatalf("response=%d %s; want %d %s with requestId", w.Code, w.Body, status, code)
|
|
}
|
|
}
|
|
|
|
type failedWriter struct {
|
|
*httptest.ResponseRecorder
|
|
headers, writes int
|
|
}
|
|
|
|
func (w *failedWriter) WriteHeader(status int) {
|
|
w.headers++
|
|
w.ResponseRecorder.WriteHeader(status)
|
|
}
|
|
|
|
func (w *failedWriter) Write([]byte) (int, error) {
|
|
w.writes++
|
|
return 0, io.ErrClosedPipe
|
|
}
|