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 }