login_e2e_test.go 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  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. wechat *fakeWechat // 微信扫码打桩(code→openid 映射;供 TestWechatE2E 配置)
  56. wechatAppID string // WechatCampus 配置的 appid(v1.2:t_identity 唯一键含 appid,seed 须与之一致)
  57. }
  58. // e2eSetup 建真库 + AutoMigrate 4 表 + 真仓储 + miniredis + 真 Issuer + AuthService + Handler + 路由应用。
  59. func e2eSetup(t *testing.T) *e2eApp {
  60. t.Helper()
  61. dsn := e2eDSNOrSkip(t)
  62. gdb, err := infra.NewDB(config.DBCfg{DSN: dsn, MaxOpenConns: 5, MaxIdleConns: 2, ConnMaxLifetimeSec: 60})
  63. if err != nil {
  64. t.Fatalf("NewDB: %v", err)
  65. }
  66. t.Cleanup(func() { _ = infra.Close(gdb) })
  67. if err := infra.Migrate(gdb, &model.User{}, &model.Identity{}, &orgmodel.Membership{}, &orgmodel.Institution{}); err != nil {
  68. t.Fatalf("Migrate: %v", err)
  69. }
  70. t.Cleanup(func() {
  71. _ = gdb.Migrator().DropTable("t_user", "t_identity", "t_membership", "t_institution")
  72. })
  73. users := repository.NewUserRepo(gdb)
  74. identities := repository.NewIdentityRepo(gdb)
  75. memberships := repository.NewMembershipRepo(gdb)
  76. mr, err := miniredis.Run()
  77. if err != nil {
  78. t.Fatalf("miniredis.Run: %v", err)
  79. }
  80. t.Cleanup(mr.Close)
  81. rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
  82. if err != nil {
  83. t.Fatalf("NewClient: %v", err)
  84. }
  85. t.Cleanup(func() { _ = rc.Close() })
  86. refreshStore := appredis.NewRefreshStore(rc)
  87. revocation := appredis.NewRevocationStore(rc, "revoked")
  88. tickets := appredis.NewLoginTicketStore(rc)
  89. wxStates := appredis.NewWechatQrcodeStore(rc)
  90. privPEM, pubKeys := genRSAKey(t)
  91. issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
  92. if err != nil {
  93. t.Fatalf("NewIssuer: %v", err)
  94. }
  95. svc, err := service.NewAuthService(service.Config{
  96. Users: users, Identities: identities, Memberships: memberships,
  97. Issuer: issuer, Tickets: tickets,
  98. AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test",
  99. Revocation: revocation,
  100. })
  101. if err != nil {
  102. t.Fatalf("NewAuthService: %v", err)
  103. }
  104. // 微信扫码登录服务(真库 identities 仓储 + miniredis 双 store + 打桩 wechat.Client)
  105. fw := &fakeWechat{byCode: map[string]string{}}
  106. const wechatAppID = "appid-e2e" // v1.2:t_identity 唯一键 (type,identifier,appid);seed 的 wechat 身份 appid 须与此一致
  107. ws, err := service.NewWechatService(service.WechatConfig{
  108. Client: fw, States: wxStates, Tickets: tickets, Identities: identities, Memberships: memberships,
  109. Campuses: []service.WechatCampus{{CampusID: 3, AppID: wechatAppID, Secret: "secret-e2e"}},
  110. CallbackURL: "https://unified.test/auth/wechat/callback",
  111. })
  112. if err != nil {
  113. t.Fatalf("NewWechatService: %v", err)
  114. }
  115. h, err := New(Config{Svc: svc, Wechat: ws})
  116. if err != nil {
  117. t.Fatalf("New Handler: %v", err)
  118. }
  119. mw, err := NewAuth(AuthConfig{PublicKeys: pubKeys, Revocation: revocation, Issuer: "https://unified.test"})
  120. if err != nil {
  121. t.Fatalf("NewAuth: %v", err)
  122. }
  123. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  124. app.Post("/auth/login", h.Login)
  125. app.Post("/auth/issue", h.Issue)
  126. app.Post("/auth/refresh", h.Refresh)
  127. app.Get("/auth/wechat/qrcode", h.WechatQrcode)
  128. app.Get("/auth/wechat/status", h.WechatStatus)
  129. app.Get("/auth/wechat/callback", h.WechatCallback)
  130. auth := app.Group("/auth")
  131. auth.Use(mw)
  132. auth.Get("/me", h.Me)
  133. auth.Post("/logout", h.Logout)
  134. return &e2eApp{t: t, app: app, pubKeys: pubKeys, revocation: revocation, gdb: gdb, wechat: fw, wechatAppID: wechatAppID}
  135. }
  136. // seedUser 落一个 argon2 用户 + username 身份 + 一条 membership(raw SQL 保证 created_at 确定)。
  137. func (a *e2eApp) seedUser(username, password string, inst, campus int64, role string) string {
  138. a.t.Helper()
  139. uid, err := idpkg.NewCanonicalUserID()
  140. if err != nil {
  141. a.t.Fatalf("生成 canonical user id: %v", err)
  142. }
  143. hash, err := credential.HashArgon2(password)
  144. if err != nil {
  145. a.t.Fatalf("HashArgon2: %v", err)
  146. }
  147. ctx := context.Background()
  148. now := time.Now()
  149. if err := a.gdb.WithContext(ctx).Create(&model.User{
  150. CanonicalUserID: uid, Username: ptr(username), PersonType: model.PersonTypeStaff,
  151. PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
  152. Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now,
  153. }).Error; err != nil {
  154. a.t.Fatalf("插入 t_user: %v", err)
  155. }
  156. if err := a.gdb.WithContext(ctx).Create(&model.Identity{
  157. CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username,
  158. Verified: true, CreatedAt: now,
  159. }).Error; err != nil {
  160. a.t.Fatalf("插入 t_identity: %v", err)
  161. }
  162. if err := a.gdb.WithContext(ctx).Exec(
  163. `INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
  164. VALUES (?, ?, ?, ?, 'active', ?, ?)`,
  165. uid, inst, campus, role, now, now,
  166. ).Error; err != nil {
  167. a.t.Fatalf("插入 t_membership: %v", err)
  168. }
  169. return uid
  170. }
  171. // post 发 POST 请求并解出统一信封。
  172. func (a *e2eApp) post(path, body string) *envelope {
  173. a.t.Helper()
  174. req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
  175. resp, err := a.app.Test(req, -1)
  176. if err != nil {
  177. a.t.Fatalf("POST %s 请求失败: %v", path, err)
  178. }
  179. var env envelope
  180. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  181. a.t.Fatalf("POST %s 解析响应: %v", path, err)
  182. }
  183. return &env
  184. }
  185. // get 发 GET 请求(无 body、无鉴权头;微信扫码公开端点用)并解出统一信封。
  186. func (a *e2eApp) get(path string) *envelope {
  187. a.t.Helper()
  188. req := httptest.NewRequest(http.MethodGet, path, nil)
  189. resp, err := a.app.Test(req, -1)
  190. if err != nil {
  191. a.t.Fatalf("GET %s 请求失败: %v", path, err)
  192. }
  193. var env envelope
  194. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  195. a.t.Fatalf("GET %s 解析响应: %v", path, err)
  196. }
  197. return &env
  198. }
  199. // getBearer 带 Bearer 头请求并解出统一信封。
  200. func (a *e2eApp) getBearer(path, bearer string) *envelope {
  201. a.t.Helper()
  202. req := httptest.NewRequest(http.MethodGet, path, nil)
  203. req.Header.Set("Authorization", "Bearer "+bearer)
  204. resp, err := a.app.Test(req, -1)
  205. if err != nil {
  206. a.t.Fatalf("GET %s 请求失败: %v", path, err)
  207. }
  208. var env envelope
  209. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  210. a.t.Fatalf("GET %s 解析响应: %v", path, err)
  211. }
  212. return &env
  213. }
  214. // TestLoginE2E 登录全链路:login① → issue② → me(验签 + scope 注入)→ 重放/越权拒绝。
  215. func TestLoginE2E(t *testing.T) {
  216. a := e2eSetup(t)
  217. uid := a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher")
  218. // ① login:真查 t_identity/t_user + argon2 验密 → 200 + loginTicket
  219. step1 := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`)
  220. if step1.Code != 200 {
  221. t.Fatalf("login code=%d msg=%s(期望 200)", step1.Code, step1.Message)
  222. }
  223. var l1 struct {
  224. LoginTicket string `json:"loginTicket"`
  225. User struct {
  226. ID string `json:"id"`
  227. } `json:"user"`
  228. Memberships []struct {
  229. InstitutionID int64 `json:"institutionId"`
  230. CampusID int64 `json:"campusId"`
  231. Role string `json:"role"`
  232. } `json:"memberships"`
  233. }
  234. if err := json.Unmarshal(step1.Data, &l1); err != nil {
  235. t.Fatalf("解析 login data: %v", err)
  236. }
  237. if l1.User.ID != uid {
  238. t.Fatalf("login user.id=%q(期望 %q)", l1.User.ID, uid)
  239. }
  240. if len(l1.Memberships) != 1 || l1.Memberships[0].Role != "teacher" {
  241. t.Fatalf("login memberships=%+v(期望 1 条 teacher)", l1.Memberships)
  242. }
  243. // ② issue:真查 t_membership + 签 token → 200 + accessToken
  244. issueBody := `{"loginTicket":"` + l1.LoginTicket + `","institutionId":11,"campusId":3}`
  245. issueRes := a.post(`/auth/issue`, issueBody)
  246. if issueRes.Code != 200 {
  247. t.Fatalf("issue code=%d msg=%s(期望 200)", issueRes.Code, issueRes.Message)
  248. }
  249. var pair struct {
  250. AccessToken string `json:"accessToken"`
  251. RefreshToken string `json:"refreshToken"`
  252. InstitutionID int64 `json:"institutionId"`
  253. Roles []string `json:"roles"`
  254. }
  255. if err := json.Unmarshal(issueRes.Data, &pair); err != nil {
  256. t.Fatalf("解析 issue data: %v", err)
  257. }
  258. if pair.AccessToken == "" || pair.RefreshToken == "" {
  259. t.Fatal("accessToken/refreshToken 缺失")
  260. }
  261. if pair.InstitutionID != 11 || len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
  262. t.Fatalf("issue pair=%+v(期望 inst=11 roles=[teacher])", pair)
  263. }
  264. // access 可被本公钥验签 + claims 正确
  265. claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken)
  266. if err != nil {
  267. t.Fatalf("验签 access 失败: %v", err)
  268. }
  269. if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 {
  270. t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid)
  271. }
  272. // ③ me:经 NewAuth 中间件验签 + scope 注入 → 200 + 正确用户
  273. meRes := a.getBearer(`/auth/me`, pair.AccessToken)
  274. if meRes.Code != 200 {
  275. t.Fatalf("me code=%d msg=%s(期望 200)", meRes.Code, meRes.Message)
  276. }
  277. var me struct {
  278. ID string `json:"id"`
  279. InstitutionID int64 `json:"institutionId"`
  280. CampusID int64 `json:"campusId"`
  281. }
  282. if err := json.Unmarshal(meRes.Data, &me); err != nil {
  283. t.Fatalf("解析 me data: %v", err)
  284. }
  285. if me.ID != uid || me.InstitutionID != 11 || me.CampusID != 3 {
  286. t.Fatalf("me=%+v(期望 %q/11/3)", me, uid)
  287. }
  288. // ④ 重放票据:② 已消费(GETDEL),再用同一 issueBody → 1001
  289. if replay := a.post(`/auth/issue`, issueBody); replay.Code != 1001 {
  290. t.Fatalf("重放 issue code=%d(期望 1001 票据已消费)", replay.Code)
  291. }
  292. // ⑤ 越权作用域:新票据挑不属于自己的 (inst,campus) → 1003
  293. step1b := a.post(`/auth/login`, `{"username":"e2euser","password":"e2e-pass-123"}`)
  294. var l1b struct {
  295. LoginTicket string `json:"loginTicket"`
  296. }
  297. _ = json.Unmarshal(step1b.Data, &l1b)
  298. if scope := a.post(`/auth/issue`, `{"loginTicket":"`+l1b.LoginTicket+`","institutionId":99,"campusId":99}`); scope.Code != 1003 {
  299. t.Fatalf("越权 issue code=%d(期望 1003 作用域越权)", scope.Code)
  300. }
  301. }
  302. // TestLoginE2E_BadPassword 真库错密:code 1001(防枚举统一文案)。
  303. //
  304. // 注意:密码必须**长度合法(≥6,过 validate min=6)**但错误——否则会在校验层被拦成 2001,
  305. // 根本走不到"防枚举 1001 统一文案"路径(本测试要验证的是后者)。用与真实密码不同的合法长度串。
  306. func TestLoginE2E_BadPassword(t *testing.T) {
  307. a := e2eSetup(t)
  308. a.seedUser("e2euser", "e2e-pass-123", 11, 3, "teacher")
  309. res := a.post(`/auth/login`, `{"username":"e2euser","password":"wrong-pass-999"}`)
  310. if res.Code != 1001 {
  311. t.Fatalf("code=%d(期望 1001 统一文案)", res.Code)
  312. }
  313. }
  314. // seedPhoneUser 落一个 argon2 用户 + phone 身份(type=phone, identifier=11 位手机号)+ 一条 membership。
  315. // 与 seedUser 的差异:身份类型为 phone(identifier=手机号;phone 类型 AppID 恒 "",见 DDL v1.2)。
  316. // 手机号须 11 位数字(Service 层 isValidPhone 权威校验;e2e 用固定合规号 13800001234)。
  317. func (a *e2eApp) seedPhoneUser(phone, password string, inst, campus int64, role string) string {
  318. a.t.Helper()
  319. uid, err := idpkg.NewCanonicalUserID()
  320. if err != nil {
  321. a.t.Fatalf("生成 canonical user id: %v", err)
  322. }
  323. hash, err := credential.HashArgon2(password)
  324. if err != nil {
  325. a.t.Fatalf("HashArgon2: %v", err)
  326. }
  327. ctx := context.Background()
  328. now := time.Now()
  329. if err := a.gdb.WithContext(ctx).Create(&model.User{
  330. CanonicalUserID: uid, PersonType: model.PersonTypeStaff,
  331. PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
  332. Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now,
  333. }).Error; err != nil {
  334. a.t.Fatalf("插入 t_user: %v", err)
  335. }
  336. if err := a.gdb.WithContext(ctx).Create(&model.Identity{
  337. CanonicalUserID: uid, Type: model.IdentityTypePhone, Identifier: phone,
  338. // phone 类型 AppID 恒 ""(DDL v1.2:phone/email/username 均空串,仅 wechat 必填)
  339. Verified: true, CreatedAt: now,
  340. }).Error; err != nil {
  341. a.t.Fatalf("插入 t_identity(phone): %v", err)
  342. }
  343. if err := a.gdb.WithContext(ctx).Exec(
  344. `INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
  345. VALUES (?, ?, ?, ?, 'active', ?, ?)`,
  346. uid, inst, campus, role, now, now,
  347. ).Error; err != nil {
  348. a.t.Fatalf("插入 t_membership: %v", err)
  349. }
  350. return uid
  351. }
  352. // TestPhoneLoginE2E 手机号+密码 登录全链路(B4 / T4 合并门禁):
  353. // phone 作为与 username 对称的第三种账号标识,走同一套两步①②协议 + 统一 RS256 token。
  354. // ① POST /auth/login {"phone","password"} → 真查 t_identity(phone)+argon2 验密 → 200 + loginTicket
  355. // ② POST /auth/issue → 200 + accessToken(本公钥可验签)
  356. // ③ GET /auth/me(验签 + scope 注入)→ 200 + 正确用户
  357. // ④ 重放票据 → 1001(防重放)
  358. func TestPhoneLoginE2E(t *testing.T) {
  359. a := e2eSetup(t)
  360. uid := a.seedPhoneUser("13800001234", "e2e-pass-123", 11, 3, "teacher")
  361. // ① login:phone 走 IdentityTypePhone 真查 t_identity + argon2 验密 → 200 + loginTicket
  362. step1 := a.post(`/auth/login`, `{"phone":"13800001234","password":"e2e-pass-123"}`)
  363. if step1.Code != 200 {
  364. t.Fatalf("phone login code=%d msg=%s(期望 200)", step1.Code, step1.Message)
  365. }
  366. var l1 struct {
  367. LoginTicket string `json:"loginTicket"`
  368. User struct {
  369. ID string `json:"id"`
  370. } `json:"user"`
  371. }
  372. if err := json.Unmarshal(step1.Data, &l1); err != nil {
  373. t.Fatalf("解析 login data: %v", err)
  374. }
  375. if l1.User.ID != uid {
  376. t.Fatalf("phone login user.id=%q(期望 %q)", l1.User.ID, uid)
  377. }
  378. if l1.LoginTicket == "" {
  379. t.Fatal("loginTicket 缺失")
  380. }
  381. // ② issue:真查 t_membership + 签 token → 200 + accessToken
  382. issueRes := a.post(`/auth/issue`, `{"loginTicket":"`+l1.LoginTicket+`","institutionId":11,"campusId":3}`)
  383. if issueRes.Code != 200 {
  384. t.Fatalf("issue code=%d msg=%s(期望 200)", issueRes.Code, issueRes.Message)
  385. }
  386. var pair struct {
  387. AccessToken string `json:"accessToken"`
  388. Roles []string `json:"roles"`
  389. }
  390. if err := json.Unmarshal(issueRes.Data, &pair); err != nil {
  391. t.Fatalf("解析 issue data: %v", err)
  392. }
  393. if pair.AccessToken == "" || len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
  394. t.Fatalf("issue pair=%+v(期望 accessToken + roles=[teacher])", pair)
  395. }
  396. // access 可被本公钥验签 + claims 正确
  397. claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken)
  398. if err != nil {
  399. t.Fatalf("验签 access 失败: %v", err)
  400. }
  401. if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 {
  402. t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid)
  403. }
  404. // ③ me:经 NewAuth 中间件验签 + scope 注入 → 200 + 正确用户
  405. meRes := a.getBearer(`/auth/me`, pair.AccessToken)
  406. if meRes.Code != 200 {
  407. t.Fatalf("me code=%d msg=%s(期望 200)", meRes.Code, meRes.Message)
  408. }
  409. var me struct{ ID string `json:"id"` }
  410. if err := json.Unmarshal(meRes.Data, &me); err != nil {
  411. t.Fatalf("解析 me data: %v", err)
  412. }
  413. if me.ID != uid {
  414. t.Fatalf("me.id=%q(期望 %q)", me.ID, uid)
  415. }
  416. // ④ 重放票据:② 已消费(GETDEL),再用同一 issueBody → 1001
  417. if replay := a.post(`/auth/issue`, `{"loginTicket":"`+l1.LoginTicket+`","institutionId":11,"campusId":3}`); replay.Code != 1001 {
  418. t.Fatalf("重放 issue code=%d(期望 1001 票据已消费)", replay.Code)
  419. }
  420. }
  421. // TestPhoneLoginE2E_BadParam 非法手机号(非 11 位):Service 层 isValidPhone 拦截 → 1001/2001 统一处理。
  422. // 注意:11 位校验在 Service(ErrBadParam→2001);handler 层 max=11 兜底拦更长串。这里用 10 位号验证 Service 路径。
  423. func TestPhoneLoginE2E_BadParam(t *testing.T) {
  424. a := e2eSetup(t)
  425. a.seedPhoneUser("13800001234", "e2e-pass-123", 11, 3, "teacher")
  426. // 10 位手机号:过 handler max=11,但 Service isValidPhone 判非 11 位 → ErrBadParam(2001)
  427. res := a.post(`/auth/login`, `{"phone":"1380000123","password":"e2e-pass-123"}`)
  428. if res.Code != 2001 {
  429. t.Fatalf("code=%d msg=%s(期望 2001 手机号位数不合法)", res.Code, res.Message)
  430. }
  431. }
  432. // seedWechatUser 落一个用户 + wechat 身份(type=wechat, identifier=openid)+ 一条 membership(raw SQL)。
  433. // 与 seedUser 的差异:无密码哈希(微信登录不验密)、身份类型为 wechat(扫码绑定的 openid)。
  434. func (a *e2eApp) seedWechatUser(openid string, inst, campus int64, role string) string {
  435. a.t.Helper()
  436. uid, err := idpkg.NewCanonicalUserID()
  437. if err != nil {
  438. a.t.Fatalf("生成 canonical user id: %v", err)
  439. }
  440. ctx := context.Background()
  441. now := time.Now()
  442. if err := a.gdb.WithContext(ctx).Create(&model.User{
  443. CanonicalUserID: uid, PersonType: model.PersonTypeStaff,
  444. Status: model.UserStatusActive, CreatedAt: now, UpdatedAt: now,
  445. }).Error; err != nil {
  446. a.t.Fatalf("插入 t_user: %v", err)
  447. }
  448. if err := a.gdb.WithContext(ctx).Create(&model.Identity{
  449. CanonicalUserID: uid, Type: model.IdentityTypeWechat, Identifier: openid,
  450. AppID: a.wechatAppID, // v1.2:唯一键含 appid,须与 WechatCampus 配置一致
  451. Verified: true, CreatedAt: now,
  452. }).Error; err != nil {
  453. a.t.Fatalf("插入 t_identity(wechat): %v", err)
  454. }
  455. if err := a.gdb.WithContext(ctx).Exec(
  456. `INSERT INTO t_membership (canonical_user_id, institution_id, campus_id, role, status, joined_at, created_at)
  457. VALUES (?, ?, ?, ?, 'active', ?, ?)`,
  458. uid, inst, campus, role, now, now,
  459. ).Error; err != nil {
  460. a.t.Fatalf("插入 t_membership: %v", err)
  461. }
  462. return uid
  463. }
  464. // TestWechatE2E 微信扫码登录全链路(真库):qrcode → callback(code→openid 真查 t_identity) → success+loginTicket
  465. // → 复用同一套 ② issue 签 token。验证"微信扫码与账密登录共用两步②"的核心契约。
  466. func TestWechatE2E(t *testing.T) {
  467. a := e2eSetup(t)
  468. uid := a.seedWechatUser("openid-e2e", 11, 3, "teacher")
  469. a.wechat.byCode["code-e2e"] = "openid-e2e" // 打桩:code→openid 映射
  470. // ① qrcode:200 + state
  471. qrEnv := a.get(`/auth/wechat/qrcode`)
  472. if qrEnv.Code != 200 {
  473. t.Fatalf("qrcode code=%d msg=%s(期望 200)", qrEnv.Code, qrEnv.Message)
  474. }
  475. var qr struct {
  476. State string `json:"state"`
  477. }
  478. if err := json.Unmarshal(qrEnv.Data, &qr); err != nil {
  479. t.Fatalf("解析 qrcode data: %v", err)
  480. }
  481. if qr.State == "" {
  482. t.Fatal("state 缺失")
  483. }
  484. // ② callback:code 换 openid(打桩)→ 真查 t_identity(wechat) → 已绑定 → success + loginTicket
  485. cbEnv := a.get(`/auth/wechat/callback?code=code-e2e&state=` + qr.State)
  486. if cbEnv.Code != 200 {
  487. t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message)
  488. }
  489. var cb struct {
  490. Status string `json:"status"`
  491. LoginTicket string `json:"loginTicket"`
  492. UserID string `json:"userId"`
  493. }
  494. if err := json.Unmarshal(cbEnv.Data, &cb); err != nil {
  495. t.Fatalf("解析 callback data: %v", err)
  496. }
  497. if cb.Status != "success" || cb.UserID != uid || cb.LoginTicket == "" {
  498. t.Fatalf("callback=%+v(期望 success + userId=%q + loginTicket)", cb, uid)
  499. }
  500. // ③ 复用两步②:携 loginTicket 签 token(证明微信登录与账密登录走同一套 ②)
  501. issueRes := a.post(`/auth/issue`, `{"loginTicket":"`+cb.LoginTicket+`","institutionId":11,"campusId":3}`)
  502. if issueRes.Code != 200 {
  503. t.Fatalf("issue code=%d msg=%s(期望 200:微信票据应可走同一套 ②)", issueRes.Code, issueRes.Message)
  504. }
  505. var pair struct {
  506. AccessToken string `json:"accessToken"`
  507. Roles []string `json:"roles"`
  508. }
  509. if err := json.Unmarshal(issueRes.Data, &pair); err != nil {
  510. t.Fatalf("解析 issue data: %v", err)
  511. }
  512. if pair.AccessToken == "" {
  513. t.Fatal("accessToken 缺失")
  514. }
  515. if len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
  516. t.Fatalf("roles=%v(期望 [teacher])", pair.Roles)
  517. }
  518. // access 可被本公钥验签 + claims 正确
  519. claims, err := token.Verify(context.Background(), a.pubKeys, a.revocation, "https://unified.test", pair.AccessToken)
  520. if err != nil {
  521. t.Fatalf("验签 access 失败: %v", err)
  522. }
  523. if claims.Sub != uid || claims.InstitutionID != 11 || claims.CampusID != 3 {
  524. t.Fatalf("claims=%+v(期望 %q/11/3)", claims, uid)
  525. }
  526. // ④ 重放 callback(已终态)→ 1001(防重放)
  527. if replay := a.get(`/auth/wechat/callback?code=code-e2e&state=` + qr.State); replay.Code != 1001 {
  528. t.Fatalf("重放 callback code=%d(期望 1001 已终态)", replay.Code)
  529. }
  530. }
  531. // TestWechatE2E_Unbound 真库未绑定 openid:callback → unbound(引导去账密登录后绑定)。
  532. func TestWechatE2E_Unbound(t *testing.T) {
  533. a := e2eSetup(t)
  534. a.seedWechatUser("openid-other", 11, 3, "teacher") // 造一个已绑定用户(保证表/数据存在)
  535. a.wechat.byCode["code-x"] = "openid-not-bound" // 该 openid 无身份记录
  536. qrEnv := a.get(`/auth/wechat/qrcode`)
  537. var qr struct{ State string `json:"state"` }
  538. _ = json.Unmarshal(qrEnv.Data, &qr)
  539. cbEnv := a.get(`/auth/wechat/callback?code=code-x&state=` + qr.State)
  540. if cbEnv.Code != 200 {
  541. t.Fatalf("callback code=%d(期望 200 unbound 语义)", cbEnv.Code)
  542. }
  543. var cb struct {
  544. Status string `json:"status"`
  545. }
  546. _ = json.Unmarshal(cbEnv.Data, &cb)
  547. if cb.Status != "unbound" {
  548. t.Fatalf("status=%q(期望 unbound)", cb.Status)
  549. }
  550. }