mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
435 lines
16 KiB
Go
435 lines
16 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/glebarez/sqlite"
|
||
"go.uber.org/zap"
|
||
"golang.org/x/crypto/bcrypt"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
|
||
"github.com/truewhile/MeBox/internal/config"
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
"github.com/truewhile/MeBox/internal/service"
|
||
)
|
||
|
||
const mobileTestSecret = "mobile-test-secret"
|
||
|
||
func newMobileTestEnv(t *testing.T) (*gin.Engine, *service.Container) {
|
||
t.Helper()
|
||
gin.SetMode(gin.TestMode)
|
||
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||
Logger: logger.Default.LogMode(logger.Silent),
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sqlDB, err := db.DB()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// :memory: 只有单连接能看到同一份数据;handler 测试足够,且避免
|
||
// shared cache 名字在并行测试间串库。
|
||
sqlDB.SetMaxOpenConns(1)
|
||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||
|
||
if err := db.AutoMigrate(
|
||
&model.User{}, &model.RefreshToken{}, &model.AuthSession{}, &model.Setting{},
|
||
&model.UserDevice{}, &model.UserPermission{},
|
||
); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
repos := repository.New(db)
|
||
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: mobileTestSecret}}
|
||
svc := service.NewWithVersion(cfg, zap.NewNop(), repos, "MeBox-test")
|
||
|
||
router := gin.New()
|
||
Register(router, cfg, zap.NewNop(), svc)
|
||
return router, svc
|
||
}
|
||
|
||
func seedMobileUser(t *testing.T, svc *service.Container, username, password string) *model.User {
|
||
t.Helper()
|
||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
u := &model.User{Username: username, PasswordHash: string(hash), Role: "user", Tier: "free", IsActive: true}
|
||
if err := svc.Repo.User.Create(t.Context(), u); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return u
|
||
}
|
||
|
||
func mobileJSONRequest(t *testing.T, router *gin.Engine, method, path, token string, body any, headers map[string]string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
var reader *bytes.Reader
|
||
if body != nil {
|
||
raw, err := json.Marshal(body)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reader = bytes.NewReader(raw)
|
||
} else {
|
||
reader = bytes.NewReader(nil)
|
||
}
|
||
req := httptest.NewRequest(method, path, reader)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
}
|
||
for k, v := range headers {
|
||
req.Header.Set(k, v)
|
||
}
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
return w
|
||
}
|
||
|
||
func decodeMobileJSON(t *testing.T, w *httptest.ResponseRecorder, out any) {
|
||
t.Helper()
|
||
if err := json.Unmarshal(w.Body.Bytes(), out); err != nil {
|
||
t.Fatalf("decode body %q: %v", w.Body.String(), err)
|
||
}
|
||
}
|
||
|
||
func TestMobileServerInfoAnonymousAndCacheable(t *testing.T) {
|
||
router, _ := newMobileTestEnv(t)
|
||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/server-info", "", nil, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||
}
|
||
var info service.ServerInfo
|
||
decodeMobileJSON(t, w, &info)
|
||
if info.ServerID == "" || info.ServerVersion != "MeBox-test" || info.APIVersion != "v1" {
|
||
t.Fatalf("unexpected server info: %+v", info)
|
||
}
|
||
if !info.Capabilities.DeviceSessions {
|
||
t.Fatalf("device_sessions capability should be true: %+v", info.Capabilities)
|
||
}
|
||
if info.Capabilities.PlaybackSessions || info.Capabilities.ReaderProgressV1 {
|
||
t.Fatalf("unimplemented capabilities must stay false: %+v", info.Capabilities)
|
||
}
|
||
if info.MinSupportedAppVersionCode < 1 {
|
||
t.Fatalf("min_supported_app_version_code = %d", info.MinSupportedAppVersionCode)
|
||
}
|
||
|
||
// 匿名访问不应要求 Authorization,也不应记录 cookie。
|
||
if len(w.Result().Cookies()) != 0 {
|
||
t.Fatalf("server-info should not set cookies")
|
||
}
|
||
}
|
||
|
||
func TestMobileAuthLifecycle(t *testing.T) {
|
||
router, svc := newMobileTestEnv(t)
|
||
user := seedMobileUser(t, svc, "alice", "secret-123")
|
||
|
||
// 1. 错误密码:401 + 稳定错误码 + request_id。
|
||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||
"username": "alice", "password": "wrong", "device_id": "device-1",
|
||
}, map[string]string{"X-Request-ID": "mobile-test-0001"})
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("wrong password status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var authErr mobileErrorBody
|
||
decodeMobileJSON(t, w, &authErr)
|
||
if authErr.Code != mobileCodeInvalidCredentials || authErr.RequestID != "mobile-test-0001" {
|
||
t.Fatalf("unexpected auth error: %+v", authErr)
|
||
}
|
||
if authErr.Retryable {
|
||
t.Fatalf("invalid credentials must not be retryable: %+v", authErr)
|
||
}
|
||
|
||
// 2. 缺少 device_id:400 INVALID_ARGUMENT。
|
||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||
"username": "alice", "password": "secret-123",
|
||
}, nil)
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Fatalf("missing device id status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
decodeMobileJSON(t, w, &authErr)
|
||
if authErr.Code != mobileCodeInvalidArgument {
|
||
t.Fatalf("code = %q, want %q", authErr.Code, mobileCodeInvalidArgument)
|
||
}
|
||
|
||
// 3. 正常登录。
|
||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||
"username": "alice", "password": "secret-123", "device_id": "device-1",
|
||
"device_name": "Pixel 9", "client_name": "MeBox Android", "client_version": "1.0.0",
|
||
}, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var loginResp struct {
|
||
AuthSessionID string `json:"auth_session_id"`
|
||
User mobileUserPayload `json:"user"`
|
||
Tokens service.TokenPair `json:"tokens"`
|
||
}
|
||
decodeMobileJSON(t, w, &loginResp)
|
||
if loginResp.AuthSessionID == "" || loginResp.Tokens.AccessToken == "" || loginResp.Tokens.RefreshToken == "" {
|
||
t.Fatalf("incomplete login response: %+v", loginResp)
|
||
}
|
||
if loginResp.User.ID != user.ID || loginResp.User.Username != "alice" {
|
||
t.Fatalf("unexpected user payload: %+v", loginResp.User)
|
||
}
|
||
claims, err := svc.Token.ValidateAccessToken(loginResp.Tokens.AccessToken)
|
||
if err != nil {
|
||
t.Fatalf("validate access token: %v", err)
|
||
}
|
||
if claims.Sid != loginResp.AuthSessionID {
|
||
t.Fatalf("token sid = %q, want %q", claims.Sid, loginResp.AuthSessionID)
|
||
}
|
||
rt, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||
if err != nil || rt == nil || rt.AuthSessionID != loginResp.AuthSessionID {
|
||
t.Fatalf("refresh token not bound to session: %+v err=%v", rt, err)
|
||
}
|
||
|
||
// 4. 会话读取。
|
||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("session status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var sessionResp struct {
|
||
AuthSessionID string `json:"auth_session_id"`
|
||
User mobileUserPayload `json:"user"`
|
||
Session *mobileSessionPayload `json:"session"`
|
||
}
|
||
decodeMobileJSON(t, w, &sessionResp)
|
||
if sessionResp.AuthSessionID != loginResp.AuthSessionID || sessionResp.Session == nil {
|
||
t.Fatalf("unexpected session response: %+v", sessionResp)
|
||
}
|
||
if sessionResp.Session.DeviceID != "device-1" || sessionResp.Session.DeviceName != "Pixel 9" {
|
||
t.Fatalf("unexpected session payload: %+v", sessionResp.Session)
|
||
}
|
||
|
||
// 5. 刷新:新令牌继续归属同一会话,旧刷新令牌被撤销。
|
||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||
"refresh_token": loginResp.Tokens.RefreshToken,
|
||
}, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("refresh status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var refreshResp struct {
|
||
AuthSessionID string `json:"auth_session_id"`
|
||
Tokens service.TokenPair `json:"tokens"`
|
||
}
|
||
decodeMobileJSON(t, w, &refreshResp)
|
||
if refreshResp.AuthSessionID != loginResp.AuthSessionID || refreshResp.Tokens.AccessToken == "" {
|
||
t.Fatalf("unexpected refresh response: %+v", refreshResp)
|
||
}
|
||
if refreshResp.Tokens.RefreshToken == loginResp.Tokens.RefreshToken {
|
||
t.Fatal("refresh token should rotate")
|
||
}
|
||
oldRT, err := svc.Repo.RefreshToken.FindByHash(t.Context(), repository.HashToken(loginResp.Tokens.RefreshToken))
|
||
if err != nil || oldRT == nil || !oldRT.Revoked {
|
||
t.Fatalf("old refresh token should be revoked: %+v err=%v", oldRT, err)
|
||
}
|
||
|
||
// 6. 用刷新后的访问令牌访问旧 API,确认 sid 会话校验在旧入口同样生效。
|
||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("old api with fresh token status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
|
||
// 7. 退出:成功且可重复。
|
||
for i := 0; i < 2; i++ {
|
||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/logout", refreshResp.Tokens.AccessToken, nil, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("logout #%d status = %d body=%s", i+1, w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
// 8. 会话撤销后:v1 会话接口 401 SESSION_REVOKED。
|
||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", refreshResp.Tokens.AccessToken, nil, nil)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("session after logout status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
decodeMobileJSON(t, w, &authErr)
|
||
if authErr.Code != mobileCodeSessionRevoked {
|
||
t.Fatalf("session after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||
}
|
||
|
||
// 9. 被踢后的未过期 JWT 不能绕过旧 API。
|
||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", refreshResp.Tokens.AccessToken, nil, nil)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("old api after logout status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
|
||
// 10. 刷新令牌随会话一起撤销。
|
||
w = mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||
"refresh_token": refreshResp.Tokens.RefreshToken,
|
||
}, nil)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("refresh after logout status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
decodeMobileJSON(t, w, &authErr)
|
||
if authErr.Code != mobileCodeSessionRevoked {
|
||
t.Fatalf("refresh after logout code = %q, want %q", authErr.Code, mobileCodeSessionRevoked)
|
||
}
|
||
}
|
||
|
||
func TestMobileV1RejectsLegacyTokenWithoutSid(t *testing.T) {
|
||
router, svc := newMobileTestEnv(t)
|
||
user := seedMobileUser(t, svc, "legacy", "secret-123")
|
||
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if claims, err := svc.Token.ValidateAccessToken(pair.AccessToken); err != nil || claims.Sid != "" {
|
||
t.Fatalf("legacy token unexpectedly has sid: %+v err=%v", claims, err)
|
||
}
|
||
|
||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", pair.AccessToken, nil, nil)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("legacy token status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var body mobileErrorBody
|
||
decodeMobileJSON(t, w, &body)
|
||
if body.Code != mobileCodeInvalidCredentials {
|
||
t.Fatalf("legacy token code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||
}
|
||
|
||
// 旧令牌仍可访问旧 API(兼容路径不能被新协议破坏)。
|
||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/me", pair.AccessToken, nil, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("legacy token on old api status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestMobileRefreshRejectsUnboundLegacyToken(t *testing.T) {
|
||
router, svc := newMobileTestEnv(t)
|
||
user := seedMobileUser(t, svc, "bob", "secret-123")
|
||
pair, err := svc.Token.IssuePair(t.Context(), user.ID, user.Role, user.Tier)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/refresh", "", map[string]any{
|
||
"refresh_token": pair.RefreshToken,
|
||
}, nil)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("legacy refresh status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var body mobileErrorBody
|
||
decodeMobileJSON(t, w, &body)
|
||
if body.Code != mobileCodeInvalidCredentials {
|
||
t.Fatalf("legacy refresh code = %q, want %q", body.Code, mobileCodeInvalidCredentials)
|
||
}
|
||
}
|
||
|
||
func TestMobileDeviceKickRevokesSession(t *testing.T) {
|
||
router, svc := newMobileTestEnv(t)
|
||
user := seedMobileUser(t, svc, "carol", "secret-123")
|
||
|
||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||
"username": "carol", "password": "secret-123", "device_id": "device-kick",
|
||
"device_name": "Kick Me", "client_name": "MeBox Android",
|
||
}, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var loginResp struct {
|
||
AuthSessionID string `json:"auth_session_id"`
|
||
Tokens service.TokenPair `json:"tokens"`
|
||
}
|
||
decodeMobileJSON(t, w, &loginResp)
|
||
|
||
if err := svc.Device.KickDevice(t.Context(), user.ID, "device-kick"); err != nil {
|
||
t.Fatalf("kick device: %v", err)
|
||
}
|
||
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||
if err != nil || session == nil || !session.Revoked {
|
||
t.Fatalf("session should be revoked after device kick: %+v err=%v", session, err)
|
||
}
|
||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", loginResp.Tokens.AccessToken, nil, nil)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("session after kick status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestMobileKickOneDeviceKeepsOtherSessions(t *testing.T) {
|
||
router, svc := newMobileTestEnv(t)
|
||
seedMobileUser(t, svc, "erin", "secret-123")
|
||
|
||
login := func(deviceID string) string {
|
||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||
"username": "erin", "password": "secret-123", "device_id": deviceID,
|
||
"device_name": deviceID, "client_name": "MeBox Android",
|
||
}, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("login %s status = %d body=%s", deviceID, w.Code, w.Body.String())
|
||
}
|
||
var resp struct {
|
||
Tokens service.TokenPair `json:"tokens"`
|
||
}
|
||
decodeMobileJSON(t, w, &resp)
|
||
return resp.Tokens.AccessToken
|
||
}
|
||
|
||
tokenA := login("device-a")
|
||
tokenB := login("device-b")
|
||
|
||
if err := svc.Device.KickDevice(t.Context(), svcUserID(t, svc, "erin"), "device-a"); err != nil {
|
||
t.Fatalf("kick device a: %v", err)
|
||
}
|
||
|
||
w := mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenA, nil, nil)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Fatalf("kicked session status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
w = mobileJSONRequest(t, router, http.MethodGet, "/api/v1/auth/session", tokenB, nil, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("other device status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|
||
|
||
func svcUserID(t *testing.T, svc *service.Container, username string) string {
|
||
t.Helper()
|
||
u, err := svc.Repo.User.FindByUsername(t.Context(), username)
|
||
if err != nil || u == nil {
|
||
t.Fatalf("find user %s: %v", username, err)
|
||
}
|
||
return u.ID
|
||
}
|
||
|
||
func TestMobileAuthDefaultsAndSessionExpiryShape(t *testing.T) {
|
||
router, svc := newMobileTestEnv(t)
|
||
seedMobileUser(t, svc, "dave", "secret-123")
|
||
|
||
w := mobileJSONRequest(t, router, http.MethodPost, "/api/v1/auth/login", "", map[string]any{
|
||
"username": "dave", "password": "secret-123", "device_id": "device-default",
|
||
}, nil)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("login status = %d body=%s", w.Code, w.Body.String())
|
||
}
|
||
var loginResp struct {
|
||
AuthSessionID string `json:"auth_session_id"`
|
||
Tokens service.TokenPair `json:"tokens"`
|
||
}
|
||
decodeMobileJSON(t, w, &loginResp)
|
||
session, err := svc.Repo.AuthSession.FindByID(t.Context(), loginResp.AuthSessionID)
|
||
if err != nil || session == nil {
|
||
t.Fatalf("load session: %v", err)
|
||
}
|
||
if session.ClientName != "MeBox Android" {
|
||
t.Fatalf("client_name default = %q", session.ClientName)
|
||
}
|
||
if session.ExpiresAt.Before(time.Now()) {
|
||
t.Fatalf("session expiry in the past: %v", session.ExpiresAt)
|
||
}
|
||
if loginResp.Tokens.TokenType != "Bearer" || loginResp.Tokens.ExpiresIn <= 0 {
|
||
t.Fatalf("unexpected token metadata: %+v", loginResp.Tokens)
|
||
}
|
||
}
|