spark-server/internal/transport/http/server_test.go
2026-09-18 17:14:38 +08:00

459 lines
16 KiB
Go

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)
}
}