// 登录端到端测试(T4-6 验收):真 DB + 真仓储 + 真 Issuer + miniredis,走 HTTP 全链路。 // // 与仓储/服务单测的差异:这里用**真实 GORM 仓储**(非 mock),覆盖"装配正确性 + 端到端"—— // ① POST /auth/login(真查 t_identity/t_user,验 argon2 密码)→ code 200 + loginTicket // ② POST /auth/issue(真查 t_membership,签 token)→ code 200 + accessToken(可被本公钥验签) // ③ GET /auth/me(经 NewAuth 中间件验签 + scope 注入)→ code 200 + 正确用户 // ④ 重放/越权 → code 1001/1003 // // 运行前提(同仓储测试纪律:DSN 走环境变量,不入库): // // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local // go test ./internal/identity/handler/ -run TestLoginE2E -v // // 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。 package handler import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "github.com/gofiber/fiber/v2" miniredis "github.com/alicebob/miniredis/v2" "gorm.io/gorm" "yuansiwei/unified-backend/internal/identity/credential" idpkg "yuansiwei/unified-backend/internal/identity/id" "yuansiwei/unified-backend/internal/identity/model" "yuansiwei/unified-backend/internal/identity/repository" "yuansiwei/unified-backend/internal/identity/service" "yuansiwei/unified-backend/internal/identity/token" "yuansiwei/unified-backend/internal/infra/config" infra "yuansiwei/unified-backend/internal/infra/db" appredis "yuansiwei/unified-backend/internal/infra/redis" orgmodel "yuansiwei/unified-backend/internal/org/model" ) // e2eDSNOrSkip 读 UNIFIED_TEST_DSN,未设置时跳过(同仓储集成测试纪律)。 func e2eDSNOrSkip(t *testing.T) string { t.Helper() dsn := os.Getenv("UNIFIED_TEST_DSN") if dsn == "" { t.Skip("未设置 UNIFIED_TEST_DSN,跳过登录端到端测试") } return dsn } // e2eApp 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 路由应用)。 type e2eApp struct { t *testing.T app *fiber.App pubKeys []string revocation *appredis.RevocationStore gdb *gorm.DB } // e2eSetup 建真库 + AutoMigrate 4 表 + 真仓储 + miniredis + 真 Issuer + AuthService + Handler + 路由应用。 func e2eSetup(t *testing.T) *e2eApp { t.Helper() dsn := e2eDSNOrSkip(t) gdb, err := infra.NewDB(config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60}) if err != nil { t.Fatalf("NewDB: %v", err) } t.Cleanup(func() { _ = infra.Close(gdb) }) if err := infra.Migrate(gdb, &model.User{}, &model.Identity{}, &orgmodel.Membership{}, &orgmodel.Institution{}); err != nil { t.Fatalf("Migrate: %v", err) } t.Cleanup(func() { _ = gdb.Migrator().DropTable("t_user", "t_identity", "t_membership", "t_institution") }) users := repository.NewUserRepo(gdb) identities := repository.NewIdentityRepo(gdb) memberships := repository.NewMembershipRepo(gdb) mr, err := miniredis.Run() if err != nil { t.Fatalf("miniredis.Run: %v", err) } t.Cleanup(mr.Close) rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()}) if err != nil { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { _ = rc.Close() }) refreshStore := appredis.NewRefreshStore(rc) revocation := appredis.NewRevocationStore(rc, "revoked") tickets := appredis.NewLoginTicketStore(rc) privPEM, pubKeys := genRSAKey(t) issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation) if err != nil { t.Fatalf("NewIssuer: %v", err) } svc, err := service.NewAuthService(service.Config{ Users: users, Identities: identities, Memberships: memberships, Issuer: issuer, Tickets: tickets, AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test", Revocation: revocation, }) if err != nil { t.Fatalf("NewAuthService: %v", err) } h, err := New(Config{Svc: svc}) if err != nil { t.Fatalf("New Handler: %v", err) } mw, err := NewAuth(AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"}) if err != nil { t.Fatalf("NewAuth: %v", err) } app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Post("/auth/login", h.Login) app.Post("/auth/issue", h.Issue) app.Post("/auth/refresh", h.Refresh) auth := app.Group("/auth") auth.Use(mw) auth.Get("/me", h.Me) auth.Post("/logout", h.Logout) return &e2eApp{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb} } // seedUser 落一个 argon2 用户 + username 身份 + 一条 membership(raw SQL 保证 created_at 确定)。 func (a *e2eApp) seedUser(username, password string, inst, campus int64, role string) string { a.t.Helper() uid, err := idpkg.NewCanonicalUserID() if err != nil { a.t.Fatalf("生成 canonical user id: %v", err) } hash, err := credential.HashArgon2(password) if err != nil { a.t.Fatalf("HashArgon2: %v", err) } ctx := context.Background() now := time.Now() if err := a.gdb.WithContext(ctx).Create(&model.User{ CanonicalUserID: uid, Username: ptr(username), PersonType: model.PersonTypeStaff, PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2), Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now, }).Error; err != nil { a.t.Fatalf("插入 t_user: %v", err) } if err := a.gdb.WithContext(ctx).Create(&model.Identity{ CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username, Verified: true, CreatedAt: now, }).Error; err != nil { a.t.Fatalf("插入 t_identity: %v", err) } if err := a.gdb.WithContext(ctx).Exec( `INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at) VALUES (?, ?, ?, ?, 'active', ?, ?)`, uid, inst, campus, role, now, now, ).Error; err != nil { a.t.Fatalf("插入 t_membership: %v", err) } return uid } // post 发 POST 请求并解出统一信封。 func (a *e2eApp) post(path, body string) *envelope { a.t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) resp, err := a.app.Test(req, -1) if err != nil { a.t.Fatalf("POST %s 请求失败: %v", path, err) } var env envelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { a.t.Fatalf("POST %s 解析响应: %v", path, err) } return &env } // getBearer 带 Bearer 头请求并解出统一信封。 func (a *e2eApp) getBearer(path, bearer string) *envelope { a.t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) req.Header.Set("Authorization", "Bearer "+bearer) resp, err := a.app.Test(req, -1) if err != nil { a.t.Fatalf("GET %s 请求失败: %v", path, err) } var env envelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { a.t.Fatalf("GET %s 解析响应: %v", path, err) } return &env } // TestLoginE2E 登录全链路:login① → issue② → me(验签 + scope 注入)→ 重放/越权拒绝。 func TestLoginE2E(t *testing.T) { a := e2eSetup(t) uid := a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher") // ① login:真查 t_identity/t_user + argon2 验密 → 200 + loginTicket step1 := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`) if step1.Code != 200 { t.Fatalf("login code=%d msg=%s(期望 200)", step1.Code, step1.Message) } var l1 struct { LoginTicket string `json:"loginTicket"` User struct { ID string `json:"id"` } `json:"user"` Memberships []struct { InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` Role string `json:"role"` } `json:"memberships"` } if err := json.Unmarshal(step1.Data, &l1); err != nil { t.Fatalf("解析 login data: %v", err) } if l1.User.ID != uid { t.Fatalf("login user.id=%q(期望 %q)", l1.User.ID, uid) } if len(l1.Memberships) != 1 || l1.Memberships[0].Role != "teacher" { t.Fatalf("login memberships=%+v(期望 1 条 teacher)", l1.Memberships) } // ② issue:真查 t_membership + 签 token → 200 + accessToken issueBody := `{"loginTicket":"` + l1.LoginTicket + `","institutionId":11,"campusId":3}` issueRes := a.post(`/auth/issue`, issueBody) if issueRes.Code != 200 { t.Fatalf("issue code=%d msg=%s(期望 200)", issueRes.Code, issueRes.Message) } var pair struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` InstitutionID int64 `json:"institutionId"` Roles []string `json:"roles"` } if err := json.Unmarshal(issueRes.Data, &pair); err != nil { t.Fatalf("解析 issue data: %v", err) } if pair.AccessToken == "" || pair.RefreshToken == "" { t.Fatal("accessToken/refreshToken 缺失") } if pair.InstitutionID != 11 || len(pair.Roles) != 1 || pair.Roles[0] != "teacher" { t.Fatalf("issue pair=%+v(期望 inst=11 roles=[teacher])", pair) } // access 可被本公钥验签 + claims 正确 claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken) if err != nil { t.Fatalf("验签 access 失败: %v", err) } if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 { t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid) } // ③ me:经 NewAuth 中间件验签 + scope 注入 → 200 + 正确用户 meRes := a.getBearer(`/auth/me`, pair.AccessToken) if meRes.Code != 200 { t.Fatalf("me code=%d msg=%s(期望 200)", meRes.Code, meRes.Message) } var me struct { ID string `json:"id"` InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` } if err := json.Unmarshal(meRes.Data, &me); err != nil { t.Fatalf("解析 me data: %v", err) } if me.ID != uid || me.InstitutionID != 11 || me.CampusID != 3 { t.Fatalf("me=%+v(期望 %q/11/3)", me, uid) } // ④ 重放票据:② 已消费(GETDEL),再用同一 issueBody → 1001 if replay := a.post(`/auth/issue`, issueBody); replay.Code != 1001 { t.Fatalf("重放 issue code=%d(期望 1001 票据已消费)", replay.Code) } // ⑤ 越权作用域:新票据挑不属于自己的 (inst,campus) → 1003 step1b := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`) var l1b struct { LoginTicket string `json:"loginTicket"` } _ = json.Unmarshal(step1b.Data, &l1b) if scope := a.post(`/auth/issue`, `{"loginTicket":"`+l1b.LoginTicket+`","institutionId":99,"campusId":99}`); scope.Code != 1003 { t.Fatalf("越权 issue code=%d(期望 1003 作用域越权)", scope.Code) } } // TestLoginE2E_BadPassword 真库错密:code 1001(防枚举统一文案)。 func TestLoginE2E_BadPassword(t *testing.T) { a := e2eSetup(t) a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher") res := a.post(`/auth/login`, `{"username":"e2euser","password":"wrong"}`) if res.Code != 1001 { t.Fatalf("code=%d(期望 1001 统一文案)", res.Code) } }