Files
backend/internal/middleware/auth_pipeline_test.go

225 lines
7.0 KiB
Go
Raw Normal View History

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 无 sidGetSessionByID 不应被调用。
}
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