package middleware import ( "context" "errors" "net/http" "net/http/httptest" "testing" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" "with_you/internal/model" "with_you/internal/pkg/jwt" "with_you/internal/service" ) // fakeIDP 实现 IdentityProvider 接口,用于测试。 type fakeIDP struct { user *model.User userErr error roles []string roleErr error session *model.Session sessErr error } func (f *fakeIDP) GetUserByID(ctx context.Context, id string) (*model.User, error) { if f.userErr != nil { return nil, f.userErr } return f.user, nil } func (f *fakeIDP) GetRolesForUser(ctx context.Context, id string) ([]string, error) { if f.roleErr != nil { return nil, f.roleErr } if f.roles == nil { return []string{}, nil } return f.roles, nil } func (f *fakeIDP) GetSessionByID(ctx context.Context, id string) (*model.Session, error) { if f.sessErr != nil { return nil, f.sessErr } return f.session, nil } // newTestJWT 构造测试用 JWT 服务。 func newTestJWT() service.JWTService { return service.NewJWTService("test-secret", 3600, 86400) } // runRequireAuth 挂载 RequireAuth 并发起请求,返回 (status, principal)。 // // Authorization 头直接设在 req 上;旧实现里 r.Use 内重复 Set 对 NewRecorder 无效,已移除。 func runRequireAuth(t *testing.T, jwtSvc service.JWTService, idp IdentityProvider, token string) (int, *testPrincipal) { t.Helper() gin.SetMode(gin.TestMode) nextCalled := false r := gin.New() r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) { nextCalled = true c.JSON(http.StatusOK, gin.H{"ok": true}) }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } r.ServeHTTP(w, req) return w.Code, &testPrincipal{called: nextCalled} } type testPrincipal struct { called bool } // TestRequireAuth_MissingHeader 缺失 Authorization → 401。 func TestRequireAuth_MissingHeader(t *testing.T) { jwtSvc := newTestJWT() idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}} code, _ := runRequireAuth(t, jwtSvc, idp, "") if code != http.StatusUnauthorized { t.Errorf("status = %d, want %d", code, http.StatusUnauthorized) } } // TestRequireAuth_RefreshTokenRejected refresh token 走 RequireAuth → 401。 func TestRequireAuth_RefreshTokenRejected(t *testing.T) { jwtSvc := newTestJWT() // 用 SessionService.Issue 签发带 sid 的 refresh token 比较繁琐,这里直接用 JWT 层签发。 jwtLib := jwt.New("test-secret", time.Hour, time.Hour) refreshTok, _ := jwtLib.GenerateRefreshToken("u1", "alice", "s1") idp := &fakeIDP{user: &model.User{ID: "u1", Status: model.UserStatusActive}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}} code, _ := runRequireAuth(t, jwtSvc, idp, refreshTok) if code != http.StatusUnauthorized { t.Errorf("refresh token should be rejected, status = %d, want %d", code, http.StatusUnauthorized) } } // TestRequireAuth_BannedUser banned 用户 → 403。 func TestRequireAuth_BannedUser(t *testing.T) { jwtSvc := newTestJWT() jwtLib := jwt.New("test-secret", time.Hour, time.Hour) accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1") idp := &fakeIDP{ user: &model.User{ID: "u1", Status: model.UserStatusBanned}, roles: []string{}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}, } code, _ := runRequireAuth(t, jwtSvc, idp, accessTok) if code != http.StatusForbidden { t.Errorf("banned user should be 403, status = %d, want %d", code, http.StatusForbidden) } } // TestRequireAuth_RevokedSession 会话已撤销 → 401。 func TestRequireAuth_RevokedSession(t *testing.T) { jwtSvc := newTestJWT() jwtLib := jwt.New("test-secret", time.Hour, time.Hour) accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1") revokedAt := time.Now().Add(-time.Minute) idp := &fakeIDP{ user: &model.User{ID: "u1", Status: model.UserStatusActive}, roles: []string{}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour), RevokedAt: &revokedAt}, } code, _ := runRequireAuth(t, jwtSvc, idp, accessTok) if code != http.StatusUnauthorized { t.Errorf("revoked session should be 401, status = %d, want %d", code, http.StatusUnauthorized) } } // TestRequireAuth_UserNotFound 用户不存在 → 401。 func TestRequireAuth_UserNotFound(t *testing.T) { jwtSvc := newTestJWT() jwtLib := jwt.New("test-secret", time.Hour, time.Hour) accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1") idp := &fakeIDP{ userErr: gorm.ErrRecordNotFound, } code, _ := runRequireAuth(t, jwtSvc, idp, accessTok) if code != http.StatusUnauthorized { t.Errorf("user not found should be 401, status = %d, want %d", code, http.StatusUnauthorized) } } // TestRequireAuth_ValidAccess 正常 access token → 200 且 next 被调用。 func TestRequireAuth_ValidAccess(t *testing.T) { jwtSvc := newTestJWT() jwtLib := jwt.New("test-secret", time.Hour, time.Hour) accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "s1") idp := &fakeIDP{ user: &model.User{ID: "u1", Status: model.UserStatusActive}, roles: []string{model.RoleUser}, session: &model.Session{ID: "s1", ExpiresAt: time.Now().Add(time.Hour)}, } // 这里需要校验 next 被调用,所以用更完整的测试。 gin.SetMode(gin.TestMode) nextCalled := false r := gin.New() r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) { nextCalled = true c.JSON(http.StatusOK, gin.H{"ok": true}) }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) req.Header.Set("Authorization", "Bearer "+accessTok) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("valid access token should be 200, status = %d, want %d", w.Code, http.StatusOK) } if !nextCalled { t.Error("next handler should be called") } } // TestRequireAuth_LegacyToken 旧 access token(无 sid)宽容通过。 func TestRequireAuth_LegacyToken(t *testing.T) { jwtSvc := newTestJWT() jwtLib := jwt.New("test-secret", time.Hour, time.Hour) // 签发不带 sid 的 access token(模拟旧客户端)。 accessTok, _ := jwtLib.GenerateAccessToken("u1", "alice", "") idp := &fakeIDP{ user: &model.User{ID: "u1", Status: model.UserStatusActive}, roles: []string{}, // 旧 token 无 sid,GetSessionByID 不应被调用。 } gin.SetMode(gin.TestMode) nextCalled := false r := gin.New() r.GET("/test", RequireAuth(jwtSvc, idp, nil), func(c *gin.Context) { nextCalled = true c.JSON(http.StatusOK, gin.H{"ok": true}) }) w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/test", nil) req.Header.Set("Authorization", "Bearer "+accessTok) r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("legacy access token should be 200 (compat window), status = %d", w.Code) } if !nextCalled { t.Error("next handler should be called for legacy token") } } // fakeIDP 的 session 路径校验:确保不传 session 时也不影响。 var _ = errors.New