package httpx import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" "go.uber.org/zap" "spark-server/internal/infrastructure/id" "spark-server/internal/infrastructure/logger" authentity "spark-server/internal/module/auth/entity" authlogic "spark-server/internal/module/auth/logic" userlogic "spark-server/internal/module/user/logic" usermodel "spark-server/internal/module/user/model" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/ratelimit" "spark-server/internal/pkg/reply" "spark-server/internal/pkg/reqctx" ) func TestHealthzEnvelopeAndTraceHeaders(t *testing.T) { r := NewEngine(zap.NewNop(), &EngineDeps{}) req := httptest.NewRequest(http.MethodGet, "/healthz", nil) req.Header.Set("X-Request-ID", "client-should-be-ignored") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("status %d", w.Code) } got := w.Header().Get("X-Request-ID") if len(got) != 11 { t.Fatalf("server request id %q headers %v", got, w.Header()) } if w.Header().Get("X-Trace-ID") != "" { t.Fatalf("trace header still set %v", w.Header()) } var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Code != "0" || env.Msg != "ok" { t.Fatalf("%+v", env) } if env.RequestID != got || len(env.RequestID) != 11 { t.Fatalf("body request_id %q header %q", env.RequestID, got) } if !bytes.Contains(env.Data, []byte(`"status":"ok"`)) { t.Fatalf("data %s", env.Data) } } type stubHealth struct { ok bool data any } func (s stubHealth) Ready(context.Context) (bool, any) { return s.ok, s.data } func TestReadyzHTTPStatus(t *testing.T) { r := NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: true, data: gin.H{"status": "ok", "mysql": "ok"}}}) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if w.Code != http.StatusOK { t.Fatalf("ready %d %s", w.Code, w.Body.String()) } r = NewEngine(zap.NewNop(), &EngineDeps{Health: stubHealth{ok: false, data: gin.H{"status": "not_ready", "mysql": "down"}}}) w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if w.Code != http.StatusServiceUnavailable { t.Fatalf("not ready %d", w.Code) } var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Code != errs.ErrAvailable.Code || !bytes.Contains(env.Data, []byte(`"mysql":"down"`)) { t.Fatalf("%+v", env) } if len(env.RequestID) != 11 { t.Fatalf("readyz request_id %q", env.RequestID) } } func TestDocsAndOpenAPI(t *testing.T) { r := NewEngine(zap.NewNop(), &EngineDeps{}) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "openapi: 3.0.3") || !strings.Contains(w.Body.String(), "bearerAuth") { t.Fatalf("spec %d %s", w.Code, w.Body.String()) } spec := w.Body.String() if !strings.Contains(spec, "name: enums") || !strings.Contains(spec, "name: errors") { t.Fatalf("spec missing enum/error tags") } w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swagger-ui") || !strings.Contains(w.Body.String(), "/docs/enums") || !strings.Contains(w.Body.String(), "/docs/errors") { t.Fatalf("docs %d", w.Code) } w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/enums", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "id=\"enum-toc\"") { t.Fatalf("enums page %d", w.Code) } w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/docs/errors", nil)) if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "id=\"error-toc\"") { t.Fatalf("errors page %d", w.Code) } } func TestJSONErrorEnvelope(t *testing.T) { r := NewEngine(zap.NewNop(), &EngineDeps{}) r.POST("/v1/probe", func(c *gin.Context) { JSONError(c, errs.ErrNotFound.WithMsg("missing")) }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/probe", strings.NewReader(`{"token":"secret"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Code != errs.ErrNotFound.Code || env.Msg != "missing" { t.Fatalf("%+v", env) } } func TestClipBodyRedactsToken(t *testing.T) { got := logger.ClipBody([]byte(`{"token":"abc","access_token":"jwt","refresh_token":"rt","verify_code":"654321","upload_url":"http://x?sig=1","x":1}`)) if strings.Contains(got, "abc") || strings.Contains(got, "jwt") || strings.Contains(got, "rt") || strings.Contains(got, "654321") || strings.Contains(got, "sig=1") || !strings.Contains(got, "***") { t.Fatalf("redact %s", got) } } func testLoginEngine(t *testing.T) *gin.Engine { t.Helper() j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) if err != nil { t.Fatal(err) } ids, err := id.New(1) if err != nil { t.Fatal(err) } users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) return NewEngine(zap.NewNop(), &EngineDeps{ Auth: j, Login: authlogic.NewLoginService(j, users), Users: users, }) } func TestLoginAndBearerAuth(t *testing.T) { r := testLoginEngine(t) r.GET("/v1/probe", func(c *gin.Context) { rc := reqctx.From(c.Request.Context()) JSON(c, gin.H{"user_id": rc.UserID, "device_id": rc.DeviceID}) }) w := httptest.NewRecorder() loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios","device_name":"iPhone"}`)) loginReq.Header.Set("Content-Type", "application/json") loginReq.Header.Set("X-App-Version", "1.0.0") loginReq.Header.Set("X-Real-IP", "203.0.113.10") r.ServeHTTP(w, loginReq) var loginEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil { t.Fatal(err) } if loginEnv.Code != "0" { t.Fatalf("login %+v body=%s", loginEnv, w.Body.String()) } var tok authentity.IssuedToken if err := json.Unmarshal(loginEnv.Data, &tok); err != nil { t.Fatal(err) } if tok.AccessToken == "" || tok.RefreshToken == "" || tok.UserID <= 0 || tok.DeviceID != 7 || tok.ExpiresIn != 604800 || tok.RefreshExpiresIn != 2592000 || tok.SparkID == "" { t.Fatalf("login data %+v", tok) } w = httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/v1/probe", nil)) var denied reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &denied); err != nil { t.Fatal(err) } if denied.Code != errs.ErrUnauthorized.Code { t.Fatalf("no bearer %+v", denied) } w = httptest.NewRecorder() okReq := httptest.NewRequest(http.MethodGet, "/v1/probe", nil) okReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) okReq.Header.Set("X-App-Version", "1.0.0") okReq.Header.Set("Accept-Language", "zh-CN") r.ServeHTTP(w, okReq) var okEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &okEnv); err != nil { t.Fatal(err) } uidJSON, _ := json.Marshal(tok.UserID) if okEnv.Code != "0" || !bytes.Contains(okEnv.Data, uidJSON) || !bytes.Contains(okEnv.Data, []byte(`"device_id":7`)) { t.Fatalf("authed %+v", okEnv) } w = httptest.NewRecorder() badDev := httptest.NewRequest(http.MethodGet, "/v1/probe", nil) badDev.Header.Set("Authorization", "Bearer "+tok.AccessToken) badDev.Header.Set("X-Device-ID", "99") r.ServeHTTP(w, badDev) var mismatch reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &mismatch); err != nil { t.Fatal(err) } if mismatch.Code != errs.ErrUnauthorized.Code { t.Fatalf("device mismatch %+v", mismatch) } } func TestUsersMeAndSparkID(t *testing.T) { r := testLoginEngine(t) w := httptest.NewRecorder() loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`)) loginReq.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, loginReq) var loginEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil { t.Fatal(err) } if loginEnv.Code != "0" { t.Fatalf("login %+v", loginEnv) } var tok authentity.IssuedToken if err := json.Unmarshal(loginEnv.Data, &tok); err != nil { t.Fatal(err) } w = httptest.NewRecorder() meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil) meReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) r.ServeHTTP(w, meReq) var meEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil { t.Fatal(err) } hasIDs := bytes.Contains(meEnv.Data, []byte(`"spark_id"`)) && bytes.Contains(meEnv.Data, []byte(`"uid"`)) && bytes.Contains(meEnv.Data, []byte(`"qrcode"`)) if meEnv.Code != "0" || !hasIDs || bytes.Contains(meEnv.Data, []byte(`spark:u:`)) || bytes.Contains(meEnv.Data, []byte(`spark:q:`)) { t.Fatalf("me %+v", meEnv) } w = httptest.NewRecorder() patchReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"spark_id":"alice_ok"}`)) patchReq.Header.Set("Content-Type", "application/json") patchReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) r.ServeHTTP(w, patchReq) var patchEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &patchEnv); err != nil { t.Fatal(err) } if patchEnv.Code != "0" || !bytes.Contains(patchEnv.Data, []byte(`"alice_ok"`)) { t.Fatalf("patch %+v", patchEnv) } w = httptest.NewRecorder() profReq := httptest.NewRequest(http.MethodPatch, "/v1/users/me", strings.NewReader(`{"nickname":"Ada","gender":2,"birthday":"1994-05-01"}`)) profReq.Header.Set("Content-Type", "application/json") profReq.Header.Set("Authorization", "Bearer "+tok.AccessToken) r.ServeHTTP(w, profReq) var profEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &profEnv); err != nil { t.Fatal(err) } if profEnv.Code != "0" || !bytes.Contains(profEnv.Data, []byte(`"Ada"`)) || !bytes.Contains(profEnv.Data, []byte(`"1994-05-01"`)) { t.Fatalf("profile patch %+v", profEnv) } } func TestRefreshAndLogout(t *testing.T) { r := testLoginEngine(t) w := httptest.NewRecorder() loginReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com","verify_code":"any","device_id":7,"device_type":"ios"}`)) loginReq.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, loginReq) var loginEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &loginEnv); err != nil { t.Fatal(err) } if loginEnv.Code != "0" { t.Fatalf("login %+v", loginEnv) } var tok authentity.IssuedToken if err := json.Unmarshal(loginEnv.Data, &tok); err != nil { t.Fatal(err) } w = httptest.NewRecorder() refReq := httptest.NewRequest(http.MethodPost, "/v1/auth/token/refresh", strings.NewReader(`{"refresh_token":"`+tok.RefreshToken+`"}`)) refReq.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, refReq) var refEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &refEnv); err != nil { t.Fatal(err) } if refEnv.Code != "0" { t.Fatalf("refresh %+v", refEnv) } var next authentity.IssuedToken if err := json.Unmarshal(refEnv.Data, &next); err != nil { t.Fatal(err) } if next.AccessToken == "" || next.AccessToken == tok.AccessToken || next.RefreshToken == tok.RefreshToken { t.Fatalf("next %+v", next) } w = httptest.NewRecorder() oldMe := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil) oldMe.Header.Set("Authorization", "Bearer "+tok.AccessToken) r.ServeHTTP(w, oldMe) var oldEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &oldEnv); err != nil { t.Fatal(err) } if oldEnv.Code != errs.ErrUnauthorized.Code { t.Fatalf("old access %+v", oldEnv) } w = httptest.NewRecorder() outReq := httptest.NewRequest(http.MethodPost, "/v1/auth/logout", nil) outReq.Header.Set("Authorization", "Bearer "+next.AccessToken) r.ServeHTTP(w, outReq) var outEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &outEnv); err != nil { t.Fatal(err) } if outEnv.Code != "0" { t.Fatalf("logout %+v", outEnv) } w = httptest.NewRecorder() meReq := httptest.NewRequest(http.MethodGet, "/v1/users/me", nil) meReq.Header.Set("Authorization", "Bearer "+next.AccessToken) r.ServeHTTP(w, meReq) var meEnv reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &meEnv); err != nil { t.Fatal(err) } if meEnv.Code != errs.ErrUnauthorized.Code { t.Fatalf("after logout %+v", meEnv) } } func TestLoginRejectsBadInput(t *testing.T) { r := testLoginEngine(t) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`)) req.Header.Set("Content-Type", "application/json") r.ServeHTTP(w, req) var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "请填写验证码") { t.Fatalf("%+v", env) } w = httptest.NewRecorder() enReq := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(`{"email":"test01@spark.com"}`)) enReq.Header.Set("Content-Type", "application/json") enReq.Header.Set("Accept-Language", "en") r.ServeHTTP(w, enReq) if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Code != errs.ErrInvalidArgument.Code || !strings.Contains(env.Msg, "Please enter the verification code") { t.Fatalf("en %+v", env) } } func TestHTTPRealIPOnContext(t *testing.T) { r := NewEngine(zap.NewNop(), &EngineDeps{}) r.GET("/v1/probe-ip", func(c *gin.Context) { JSON(c, gin.H{"real_ip": reqctx.RealIP(c.Request.Context())}) }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/v1/probe-ip", nil) req.Header.Set("X-Real-IP", "203.0.113.10") r.ServeHTTP(w, req) var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"real_ip":"203.0.113.10"`)) { t.Fatalf("%+v", env) } } func TestHTTPLanguageOnContext(t *testing.T) { r := NewEngine(zap.NewNop(), &EngineDeps{}) r.GET("/v1/probe-lang", func(c *gin.Context) { JSON(c, gin.H{"lang": reqctx.Language(c.Request.Context())}) }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/v1/probe-lang", nil) req.Header.Set("X-Language", "zh-TW") r.ServeHTTP(w, req) var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } if env.Code != "0" || !bytes.Contains(env.Data, []byte(`"lang":"zh-TW"`)) { t.Fatalf("%+v", env) } } func TestLoginRateLimitedByRealIP(t *testing.T) { j, err := authlogic.NewJWT(&authlogic.Options{Secret: "test-secret-test-secret", Issuer: "spark-im"}) if err != nil { t.Fatal(err) } ids, err := id.New(1) if err != nil { t.Fatal(err) } users := userlogic.NewService(usermodel.NewMemoryRepo(), usermodel.NewMemoryCodes(), ids, nil) r := NewEngine(zap.NewNop(), &EngineDeps{ Auth: j, Login: authlogic.NewLoginService(j, users), Users: users, AuthLimiter: ratelimit.New(1), }) body := `{"email":"test01@spark.com","verify_code":"x","device_id":7,"device_type":"ios"}` hit := func() reply.Envelope { w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/v1/auth/login", strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Real-IP", "203.0.113.10") r.ServeHTTP(w, req) var env reply.Envelope if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatal(err) } return env } if env := hit(); env.Code != "0" { t.Fatalf("first %+v", env) } if env := hit(); env.Code != errs.ErrRateLimited.Code { t.Fatalf("second %+v", env) } }