login_e2e_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. // 登录端到端测试(T4-6 验收):真 DB + 真仓储 + 真 Issuer + miniredis,走 HTTP 全链路。
  2. //
  3. // 与仓储/服务单测的差异:这里用**真实 GORM 仓储**(非 mock),覆盖"装配正确性 + 端到端"——
  4. // ① POST /auth/login(真查 t_identity/t_user,验 argon2 密码)→ code 200 + loginTicket
  5. // ② POST /auth/issue(真查 t_membership,签 token)→ code 200 + accessToken(可被本公钥验签)
  6. // ③ GET /auth/me(经 NewAuth 中间件验签 + scope 注入)→ code 200 + 正确用户
  7. // ④ 重放/越权 → code 1001/1003
  8. //
  9. // 运行前提(同仓储测试纪律:DSN 走环境变量,不入库):
  10. //
  11. // set UNIFIED_TEST_DSN=dev:xxxx@tcp(127.0.0.1:3306)/e2e_test?charset=utf8mb4&parseTime=True&loc=Local
  12. // go test ./internal/identity/handler/ -run TestLoginE2E -v
  13. //
  14. // 未设置 UNIFIED_TEST_DSN 时自动跳过(CI / 无库环境 go test ./... 不受影响)。
  15. package handler
  16. import (
  17. "context"
  18. "encoding/json"
  19. "net/http"
  20. "net/http/httptest"
  21. "os"
  22. "strings"
  23. "testing"
  24. "time"
  25. "github.com/gofiber/fiber/v2"
  26. miniredis "github.com/alicebob/miniredis/v2"
  27. "gorm.io/gorm"
  28. "yuansiwei/unified-backend/internal/identity/credential"
  29. idpkg "yuansiwei/unified-backend/internal/identity/id"
  30. "yuansiwei/unified-backend/internal/identity/model"
  31. "yuansiwei/unified-backend/internal/identity/repository"
  32. "yuansiwei/unified-backend/internal/identity/service"
  33. "yuansiwei/unified-backend/internal/identity/token"
  34. "yuansiwei/unified-backend/internal/infra/config"
  35. infra "yuansiwei/unified-backend/internal/infra/db"
  36. appredis "yuansiwei/unified-backend/internal/infra/redis"
  37. orgmodel "yuansiwei/unified-backend/internal/org/model"
  38. )
  39. // e2eDSNOrSkip 读 UNIFIED_TEST_DSN,未设置时跳过(同仓储集成测试纪律)。
  40. func e2eDSNOrSkip(t *testing.T) string {
  41. t.Helper()
  42. dsn := os.Getenv("UNIFIED_TEST_DSN")
  43. if dsn == "" {
  44. t.Skip("未设置 UNIFIED_TEST_DSN,跳过登录端到端测试")
  45. }
  46. return dsn
  47. }
  48. // e2eApp 端到端测试句柄(真库 + 真仓储 + miniredis + 真 Issuer + 路由应用)。
  49. type e2eApp struct {
  50. t *testing.T
  51. app *fiber.App
  52. pubKeys []string
  53. revocation *appredis.RevocationStore
  54. gdb *gorm.DB
  55. }
  56. // e2eSetup 建真库 + AutoMigrate 4 表 + 真仓储 + miniredis + 真 Issuer + AuthService + Handler + 路由应用。
  57. func e2eSetup(t *testing.T) *e2eApp {
  58. t.Helper()
  59. dsn := e2eDSNOrSkip(t)
  60. gdb, err := infra.NewDB(config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60})
  61. if err != nil {
  62. t.Fatalf("NewDB: %v", err)
  63. }
  64. t.Cleanup(func() { _ = infra.Close(gdb) })
  65. if err := infra.Migrate(gdb, &model.User{}, &model.Identity{}, &orgmodel.Membership{}, &orgmodel.Institution{}); err != nil {
  66. t.Fatalf("Migrate: %v", err)
  67. }
  68. t.Cleanup(func() {
  69. _ = gdb.Migrator().DropTable("t_user", "t_identity", "t_membership", "t_institution")
  70. })
  71. users := repository.NewUserRepo(gdb)
  72. identities := repository.NewIdentityRepo(gdb)
  73. memberships := repository.NewMembershipRepo(gdb)
  74. mr, err := miniredis.Run()
  75. if err != nil {
  76. t.Fatalf("miniredis.Run: %v", err)
  77. }
  78. t.Cleanup(mr.Close)
  79. rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
  80. if err != nil {
  81. t.Fatalf("NewClient: %v", err)
  82. }
  83. t.Cleanup(func() { _ = rc.Close() })
  84. refreshStore := appredis.NewRefreshStore(rc)
  85. revocation := appredis.NewRevocationStore(rc, "revoked")
  86. tickets := appredis.NewLoginTicketStore(rc)
  87. privPEM, pubKeys := genRSAKey(t)
  88. issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
  89. if err != nil {
  90. t.Fatalf("NewIssuer: %v", err)
  91. }
  92. svc, err := service.NewAuthService(service.Config{
  93. Users: users, Identities: identities, Memberships: memberships,
  94. Issuer: issuer, Tickets: tickets,
  95. AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test",
  96. Revocation: revocation,
  97. })
  98. if err != nil {
  99. t.Fatalf("NewAuthService: %v", err)
  100. }
  101. h, err := New(Config{Svc: svc})
  102. if err != nil {
  103. t.Fatalf("New Handler: %v", err)
  104. }
  105. mw, err := NewAuth(AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
  106. if err != nil {
  107. t.Fatalf("NewAuth: %v", err)
  108. }
  109. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  110. app.Post("/auth/login", h.Login)
  111. app.Post("/auth/issue", h.Issue)
  112. app.Post("/auth/refresh", h.Refresh)
  113. auth := app.Group("/auth")
  114. auth.Use(mw)
  115. auth.Get("/me", h.Me)
  116. auth.Post("/logout", h.Logout)
  117. return &e2eApp{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb}
  118. }
  119. // seedUser 落一个 argon2 用户 + username 身份 + 一条 membership(raw SQL 保证 created_at 确定)。
  120. func (a *e2eApp) seedUser(username, password string, inst, campus int64, role string) string {
  121. a.t.Helper()
  122. uid, err := idpkg.NewCanonicalUserID()
  123. if err != nil {
  124. a.t.Fatalf("生成 canonical user id: %v", err)
  125. }
  126. hash, err := credential.HashArgon2(password)
  127. if err != nil {
  128. a.t.Fatalf("HashArgon2: %v", err)
  129. }
  130. ctx := context.Background()
  131. now := time.Now()
  132. if err := a.gdb.WithContext(ctx).Create(&model.User{
  133. CanonicalUserID: uid, Username: ptr(username), PersonType: model.PersonTypeStaff,
  134. PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
  135. Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now,
  136. }).Error; err != nil {
  137. a.t.Fatalf("插入 t_user: %v", err)
  138. }
  139. if err := a.gdb.WithContext(ctx).Create(&model.Identity{
  140. CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username,
  141. Verified: true, CreatedAt: now,
  142. }).Error; err != nil {
  143. a.t.Fatalf("插入 t_identity: %v", err)
  144. }
  145. if err := a.gdb.WithContext(ctx).Exec(
  146. `INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
  147. VALUES (?, ?, ?, ?, 'active', ?, ?)`,
  148. uid, inst, campus, role, now, now,
  149. ).Error; err != nil {
  150. a.t.Fatalf("插入 t_membership: %v", err)
  151. }
  152. return uid
  153. }
  154. // post 发 POST 请求并解出统一信封。
  155. func (a *e2eApp) post(path, body string) *envelope {
  156. a.t.Helper()
  157. req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
  158. resp, err := a.app.Test(req, -1)
  159. if err != nil {
  160. a.t.Fatalf("POST %s 请求失败: %v", path, err)
  161. }
  162. var env envelope
  163. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  164. a.t.Fatalf("POST %s 解析响应: %v", path, err)
  165. }
  166. return &env
  167. }
  168. // getBearer 带 Bearer 头请求并解出统一信封。
  169. func (a *e2eApp) getBearer(path, bearer string) *envelope {
  170. a.t.Helper()
  171. req := httptest.NewRequest(http.MethodGet, path, nil)
  172. req.Header.Set("Authorization", "Bearer "+bearer)
  173. resp, err := a.app.Test(req, -1)
  174. if err != nil {
  175. a.t.Fatalf("GET %s 请求失败: %v", path, err)
  176. }
  177. var env envelope
  178. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  179. a.t.Fatalf("GET %s 解析响应: %v", path, err)
  180. }
  181. return &env
  182. }
  183. // TestLoginE2E 登录全链路:login① → issue② → me(验签 + scope 注入)→ 重放/越权拒绝。
  184. func TestLoginE2E(t *testing.T) {
  185. a := e2eSetup(t)
  186. uid := a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher")
  187. // ① login:真查 t_identity/t_user + argon2 验密 → 200 + loginTicket
  188. step1 := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`)
  189. if step1.Code != 200 {
  190. t.Fatalf("login code=%d msg=%s(期望 200)", step1.Code, step1.Message)
  191. }
  192. var l1 struct {
  193. LoginTicket string `json:"loginTicket"`
  194. User struct {
  195. ID string `json:"id"`
  196. } `json:"user"`
  197. Memberships []struct {
  198. InstitutionID int64 `json:"institutionId"`
  199. CampusID int64 `json:"campusId"`
  200. Role string `json:"role"`
  201. } `json:"memberships"`
  202. }
  203. if err := json.Unmarshal(step1.Data, &l1); err != nil {
  204. t.Fatalf("解析 login data: %v", err)
  205. }
  206. if l1.User.ID != uid {
  207. t.Fatalf("login user.id=%q(期望 %q)", l1.User.ID, uid)
  208. }
  209. if len(l1.Memberships) != 1 || l1.Memberships[0].Role != "teacher" {
  210. t.Fatalf("login memberships=%+v(期望 1 条 teacher)", l1.Memberships)
  211. }
  212. // ② issue:真查 t_membership + 签 token → 200 + accessToken
  213. issueBody := `{"loginTicket":"` + l1.LoginTicket + `","institutionId":11,"campusId":3}`
  214. issueRes := a.post(`/auth/issue`, issueBody)
  215. if issueRes.Code != 200 {
  216. t.Fatalf("issue code=%d msg=%s(期望 200)", issueRes.Code, issueRes.Message)
  217. }
  218. var pair struct {
  219. AccessToken string `json:"accessToken"`
  220. RefreshToken string `json:"refreshToken"`
  221. InstitutionID int64 `json:"institutionId"`
  222. Roles []string `json:"roles"`
  223. }
  224. if err := json.Unmarshal(issueRes.Data, &pair); err != nil {
  225. t.Fatalf("解析 issue data: %v", err)
  226. }
  227. if pair.AccessToken == "" || pair.RefreshToken == "" {
  228. t.Fatal("accessToken/refreshToken 缺失")
  229. }
  230. if pair.InstitutionID != 11 || len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
  231. t.Fatalf("issue pair=%+v(期望 inst=11 roles=[teacher])", pair)
  232. }
  233. // access 可被本公钥验签 + claims 正确
  234. claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken)
  235. if err != nil {
  236. t.Fatalf("验签 access 失败: %v", err)
  237. }
  238. if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 {
  239. t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid)
  240. }
  241. // ③ me:经 NewAuth 中间件验签 + scope 注入 → 200 + 正确用户
  242. meRes := a.getBearer(`/auth/me`, pair.AccessToken)
  243. if meRes.Code != 200 {
  244. t.Fatalf("me code=%d msg=%s(期望 200)", meRes.Code, meRes.Message)
  245. }
  246. var me struct {
  247. ID string `json:"id"`
  248. InstitutionID int64 `json:"institutionId"`
  249. CampusID int64 `json:"campusId"`
  250. }
  251. if err := json.Unmarshal(meRes.Data, &me); err != nil {
  252. t.Fatalf("解析 me data: %v", err)
  253. }
  254. if me.ID != uid || me.InstitutionID != 11 || me.CampusID != 3 {
  255. t.Fatalf("me=%+v(期望 %q/11/3)", me, uid)
  256. }
  257. // ④ 重放票据:② 已消费(GETDEL),再用同一 issueBody → 1001
  258. if replay := a.post(`/auth/issue`, issueBody); replay.Code != 1001 {
  259. t.Fatalf("重放 issue code=%d(期望 1001 票据已消费)", replay.Code)
  260. }
  261. // ⑤ 越权作用域:新票据挑不属于自己的 (inst,campus) → 1003
  262. step1b := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`)
  263. var l1b struct {
  264. LoginTicket string `json:"loginTicket"`
  265. }
  266. _ = json.Unmarshal(step1b.Data, &l1b)
  267. if scope := a.post(`/auth/issue`, `{"loginTicket":"`+l1b.LoginTicket+`","institutionId":99,"campusId":99}`); scope.Code != 1003 {
  268. t.Fatalf("越权 issue code=%d(期望 1003 作用域越权)", scope.Code)
  269. }
  270. }
  271. // TestLoginE2E_BadPassword 真库错密:code 1001(防枚举统一文案)。
  272. func TestLoginE2E_BadPassword(t *testing.T) {
  273. a := e2eSetup(t)
  274. a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher")
  275. res := a.post(`/auth/login`, `{"username":"e2euser","password":"wrong"}`)
  276. if res.Code != 1001 {
  277. t.Fatalf("code=%d(期望 1001 统一文案)", res.Code)
  278. }
  279. }