package handler import ( "net/http" "testing" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/internal/service" "github.com/wangjia/jiu/backend/testutil" ) // setupSessionSecurityRouter 挂 JWT 中间件 + 用户管理路由 + 一个受保护 GET, // 用于验证「改密/禁用即时下线」与中间件对 is_active 的兜底校验。 func setupSessionSecurityRouter(db *gorm.DB) *gin.Engine { userH := NewUserHandler(db) r := gin.New() r.Use(gin.Recovery()) api := r.Group("/api/v1") api.Use(middleware.JWT(db)) api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) users := api.Group("/users") users.PUT("/:id", userH.Update) users.PUT("/:id/reset-password", userH.ResetPassword) return r } // #2 管理员重置某用户密码 → 该用户全部活跃会话立即失效。 func TestResetPassword_RevokesUserSessions(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SEC01") admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator") r := setupSessionSecurityRouter(db) svc := service.NewAuthService(db) // 目标用户登录,拿到带 sid 的 access token(建出真实会话行)。 pair, _, err := svc.Login("SEC01", "clerk", "password123", service.DeviceInfo{Platform: "windows"}) require.NoError(t, err) targetToken := pair.AccessToken // 改密前:目标 token 可用。 require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code) // 管理员重置目标用户密码。 adminToken := getAuthToken(admin.ID, shop.ID, "admin") w := makeRequest(r, "PUT", "/api/v1/users/2/reset-password", adminToken, jsonBody("password", "newpass123")) require.Equal(t, http.StatusOK, w.Code) // 改密后:目标 token 立即 401 SESSION_REVOKED。 w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"]) // 会话审计字段。 var sess model.UserSession require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error) assert.Equal(t, "pwd_reset", sess.RevokedReason) require.NotNil(t, sess.RevokedBy) assert.Equal(t, admin.ID, *sess.RevokedBy) } // #3 管理员停用某用户 → 该用户活跃会话被吊销,原 token 立即 401。 func TestDisableUser_RevokesSessions(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SEC02") admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin") target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator") r := setupSessionSecurityRouter(db) svc := service.NewAuthService(db) pair, _, err := svc.Login("SEC02", "clerk", "password123", service.DeviceInfo{Platform: "windows"}) require.NoError(t, err) targetToken := pair.AccessToken adminToken := getAuthToken(admin.ID, shop.ID, "admin") w := makeRequest(r, "PUT", "/api/v1/users/2", adminToken, jsonBody("is_active", false)) require.Equal(t, http.StatusOK, w.Code) w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"]) var sess model.UserSession require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error) assert.Equal(t, "disabled", sess.RevokedReason) } // #3 兜底:直接改库把 is_active=0(不动会话)→ 中间件 JOIN 校验返回 401 USER_DISABLED。 func TestMiddleware_RejectsDisabledUserOnDirectDBFlip(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "SEC03") target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator") r := setupSessionSecurityRouter(db) svc := service.NewAuthService(db) pair, _, err := svc.Login("SEC03", "clerk", "password123", service.DeviceInfo{Platform: "windows"}) require.NoError(t, err) targetToken := pair.AccessToken require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code) // 绕过 handler 直接改库:会话仍为活跃(revoked_at 为空),仅 is_active=0。 require.NoError(t, db.Exec("UPDATE users SET is_active = 0 WHERE id = ?", target.ID).Error) w := makeRequest(r, "GET", "/api/v1/ping", targetToken, nil) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Equal(t, "USER_DISABLED", parseResponse(w)["code"]) // 会话本身未被吊销,证明拦截来自中间件对 is_active 的兜底校验。 var sess model.UserSession require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error) assert.Nil(t, sess.RevokedAt) }