handler_test.go 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114
  1. // Handler 单测(T4-4 验收):/auth/* 端点端到端(mock 仓储 + 真 Issuer + miniredis,不连真实 DB/Redis)。
  2. //
  3. // 覆盖:
  4. // - 入参校验(validator)→ code 2001 / 2002
  5. // - 两步登录①② → code 200(含 loginTicket / accessToken)
  6. // - 防枚举统一文案 → code 1001(未知账号 / 密码错 / 禁用)
  7. // - 票据失效 / 重放 / 作用域越权 → code 1001 / 1003
  8. // - Me(受保护,读中间件写入的 claims)/ Logout(恒 200 幂等)/ Refresh
  9. // - 微信占位 → code 5001(暂未开放)
  10. //
  11. // 断言纪律(响应契约 = code-based,HTTP 恒 200):只断 body.code(铁律 6),
  12. // 不依赖 HTTP 状态码(恒 200)。
  13. package handler
  14. import (
  15. "context"
  16. "crypto/md5"
  17. "crypto/rand"
  18. "crypto/rsa"
  19. "crypto/x509"
  20. "encoding/hex"
  21. "encoding/json"
  22. "encoding/pem"
  23. "net/http"
  24. "net/http/httptest"
  25. "strings"
  26. "testing"
  27. "time"
  28. miniredis "github.com/alicebob/miniredis/v2"
  29. "github.com/gofiber/fiber/v2"
  30. "yuansiwei/unified-backend/internal/identity/credential"
  31. "yuansiwei/unified-backend/internal/identity/model"
  32. "yuansiwei/unified-backend/internal/identity/repository"
  33. "yuansiwei/unified-backend/internal/identity/service"
  34. "yuansiwei/unified-backend/internal/identity/token"
  35. "yuansiwei/unified-backend/internal/infra/config"
  36. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  37. appredis "yuansiwei/unified-backend/internal/infra/redis"
  38. orgmodel "yuansiwei/unified-backend/internal/org/model"
  39. )
  40. // ---- 仓储 mock(实现 repository 接口,纯内存;与 service 单测同源逻辑)----
  41. type mockUsers struct{ byUID map[string]*model.User }
  42. func (m *mockUsers) GetByCanonicalUserID(_ context.Context, uid string) (*model.User, error) {
  43. u, ok := m.byUID[uid]
  44. if !ok {
  45. return nil, apperrors.ErrNotFound
  46. }
  47. return u, nil
  48. }
  49. func (m *mockUsers) UpdatePasswordLazyUpgrade(_ context.Context, uid, newHash string) error {
  50. u, ok := m.byUID[uid]
  51. if !ok {
  52. return apperrors.ErrNotFound
  53. }
  54. u.PasswordHash = &newHash
  55. u.PasswordSalt = nil
  56. hv := model.HashVersionArgon2
  57. u.HashVersion = &hv
  58. return nil
  59. }
  60. type mockIdentities struct{ byTypeIdentAppID map[string]map[string]map[string]*model.Identity }
  61. func newMockIdentities() *mockIdentities {
  62. return &mockIdentities{byTypeIdentAppID: map[string]map[string]map[string]*model.Identity{}}
  63. }
  64. func (m *mockIdentities) add(typ, identifier, appid string, it *model.Identity) {
  65. if m.byTypeIdentAppID[typ] == nil {
  66. m.byTypeIdentAppID[typ] = map[string]map[string]*model.Identity{}
  67. }
  68. if m.byTypeIdentAppID[typ][identifier] == nil {
  69. m.byTypeIdentAppID[typ][identifier] = map[string]*model.Identity{}
  70. }
  71. m.byTypeIdentAppID[typ][identifier][appid] = it
  72. }
  73. func (m *mockIdentities) GetByTypeIdentifierAppID(_ context.Context, typ, identifier, appid string) (*model.Identity, error) {
  74. it, ok := m.byTypeIdentAppID[typ][identifier][appid]
  75. if !ok {
  76. return nil, apperrors.ErrNotFound
  77. }
  78. return it, nil
  79. }
  80. type mockMemberships struct{ byUser map[string][]orgmodel.Membership }
  81. func (m *mockMemberships) add(uid string, ms ...orgmodel.Membership) {
  82. m.byUser[uid] = append(m.byUser[uid], ms...)
  83. }
  84. func (m *mockMemberships) ListByCanonicalUserID(_ context.Context, uid string) ([]orgmodel.Membership, error) {
  85. list := m.byUser[uid]
  86. if list == nil {
  87. return []orgmodel.Membership{}, nil
  88. }
  89. return list, nil
  90. }
  91. func (m *mockMemberships) GetByUserInstitutionCampus(_ context.Context, uid string, inst, campus int64) (*orgmodel.Membership, error) {
  92. var best *orgmodel.Membership
  93. for i := range m.byUser[uid] {
  94. mm := &m.byUser[uid][i]
  95. if mm.InstitutionID == inst && mm.CampusID == campus {
  96. if best == nil || mm.CreatedAt.After(best.CreatedAt) {
  97. best = mm
  98. }
  99. }
  100. }
  101. if best == nil {
  102. return nil, apperrors.ErrNotFound
  103. }
  104. return best, nil
  105. }
  106. // 编译期断言:mock 满足 repository 接口(签名漂移即编译失败)。
  107. var (
  108. _ repository.UserRepository = (*mockUsers)(nil)
  109. _ repository.IdentityRepository = (*mockIdentities)(nil)
  110. _ repository.MembershipRepository = (*mockMemberships)(nil)
  111. )
  112. // ---- fixture ----
  113. // fakeWechat 微信客户端打桩:code→openid 映射(默认返回 "openid-fixed";可注入失败)。
  114. type fakeWechat struct {
  115. byCode map[string]string
  116. fail bool
  117. called int
  118. }
  119. func (f *fakeWechat) ExchangeCode(_ context.Context, _, _, code, _ string) (string, error) {
  120. f.called++
  121. if f.fail {
  122. return "", &wechatErr{}
  123. }
  124. if oid, ok := f.byCode[code]; ok {
  125. return oid, nil
  126. }
  127. return "", &wechatErr{}
  128. }
  129. // wechatErr 实现 wechat 包的"换 openid 失败"错误形态(IsTokenError 兼容:此处仅需非 nil error)。
  130. type wechatErr struct{}
  131. func (*wechatErr) Error() string { return "wechat: 换 openid 失败(测试打桩)" }
  132. type fixture struct {
  133. t *testing.T
  134. users *mockUsers
  135. identities *mockIdentities
  136. memberships *mockMemberships
  137. wechat *fakeWechat
  138. wxSvc *service.WechatService
  139. handler *Handler
  140. pubKeys []string
  141. privPEM string
  142. issuer *token.Issuer
  143. revoke *appredis.RevocationStore
  144. refresh *appredis.RefreshStore
  145. }
  146. // newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService + Handler。
  147. func newFixture(t *testing.T) *fixture {
  148. t.Helper()
  149. mr, err := miniredis.Run()
  150. if err != nil {
  151. t.Fatalf("miniredis.Run: %v", err)
  152. }
  153. t.Cleanup(mr.Close)
  154. rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
  155. if err != nil {
  156. t.Fatalf("NewClient: %v", err)
  157. }
  158. t.Cleanup(func() { _ = rc.Close() })
  159. privPEM, pubKeys := genRSAKey(t)
  160. refreshStore := appredis.NewRefreshStore(rc)
  161. revocation := appredis.NewRevocationStore(rc, "revoked")
  162. tickets := appredis.NewLoginTicketStore(rc)
  163. wxStates := appredis.NewWechatQrcodeStore(rc)
  164. issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
  165. if err != nil {
  166. t.Fatalf("NewIssuer: %v", err)
  167. }
  168. users := &mockUsers{byUID: map[string]*model.User{}}
  169. ids := newMockIdentities()
  170. mergs := &mockMemberships{byUser: map[string][]orgmodel.Membership{}}
  171. svc, err := service.NewAuthService(service.Config{
  172. Users: users, Identities: ids, Memberships: mergs,
  173. Issuer: issuer, Tickets: tickets,
  174. AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test",
  175. Revocation: revocation, LoginTicketTTL: 5 * time.Minute,
  176. })
  177. if err != nil {
  178. t.Fatalf("NewAuthService: %v", err)
  179. }
  180. fw := &fakeWechat{byCode: map[string]string{}}
  181. wxSvc, err := service.NewWechatService(service.WechatConfig{
  182. Client: fw, States: wxStates, Tickets: tickets, Identities: ids, Memberships: mergs,
  183. Campuses: []service.WechatCampus{{CampusID: 3, AppID: "appid-test", Secret: "secret-test"}},
  184. CallbackURL: "https://unified.test/auth/wechat/callback",
  185. })
  186. if err != nil {
  187. t.Fatalf("NewWechatService: %v", err)
  188. }
  189. h, err := New(Config{Svc: svc, Wechat: wxSvc})
  190. if err != nil {
  191. t.Fatalf("New Handler: %v", err)
  192. }
  193. return &fixture{
  194. t: t, users: users, identities: ids, memberships: mergs, wechat: fw, wxSvc: wxSvc, handler: h,
  195. pubKeys: pubKeys, privPEM: privPEM, issuer: issuer, revoke: revocation, refresh: refreshStore,
  196. }
  197. }
  198. // genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。
  199. func genRSAKey(t *testing.T) (string, []string) {
  200. t.Helper()
  201. key, err := rsa.GenerateKey(rand.Reader, 2048)
  202. if err != nil {
  203. t.Fatalf("生成 RSA 私钥: %v", err)
  204. }
  205. privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
  206. pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
  207. if err != nil {
  208. t.Fatalf("序列化公钥: %v", err)
  209. }
  210. pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
  211. return privPEM, []string{pubPEM}
  212. }
  213. // addWechatUser 造一个用户 + wechat 身份(type=wechat, identifier=openid, appid=appid-test;扫码登录绑定用)。
  214. // v1.2:wechat 类型 appid 进唯一键,须与 WechatCampus 配置一致("appid-test")。
  215. func (fx *fixture) addWechatUser(uid, openid string) {
  216. fx.t.Helper()
  217. if fx.users.byUID[uid] == nil {
  218. fx.users.byUID[uid] = &model.User{CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive}
  219. }
  220. fx.identities.add(model.IdentityTypeWechat, openid, "appid-test",
  221. &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeWechat, Identifier: openid, AppID: "appid-test", Verified: true})
  222. }
  223. // addUser 造一个 argon2 用户 + username 身份(测试夹具快捷构造)。
  224. func (fx *fixture) addArgon2User(uid, username, password string) {
  225. fx.t.Helper()
  226. hash, err := credential.HashArgon2(password)
  227. if err != nil {
  228. fx.t.Fatalf("HashArgon2: %v", err)
  229. }
  230. fx.users.byUID[uid] = &model.User{
  231. CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
  232. PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
  233. }
  234. fx.identities.add(model.IdentityTypeUsername, username, "",
  235. &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username})
  236. }
  237. func ptr[T any](v T) *T { return &v }
  238. // ---- 响应解析 ----
  239. // envelope 统一响应信封(断言用;Data 留原始 JSON 供按需再解析)。
  240. type envelope struct {
  241. Code int `json:"code"`
  242. Message string `json:"message"`
  243. Data json.RawMessage `json:"data"`
  244. Success bool `json:"success"`
  245. }
  246. // do 发一个请求并解出统一信封(HTTP 恒 200,断 body.code)。
  247. func do(t *testing.T, app *fiber.App, method, path, body string, headers map[string]string) *envelope {
  248. t.Helper()
  249. var req *http.Request
  250. if method == http.MethodGet {
  251. req = httptest.NewRequest(method, path, nil)
  252. } else {
  253. req = httptest.NewRequest(method, path, strings.NewReader(body))
  254. }
  255. for k, v := range headers {
  256. req.Header.Set(k, v)
  257. }
  258. resp, err := app.Test(req, -1)
  259. if err != nil {
  260. t.Fatalf("%s %s 请求失败: %v", method, path, err)
  261. }
  262. if resp.StatusCode != http.StatusOK {
  263. t.Fatalf("%s %s HTTP 状态码=%d(铁律 6 要求恒 200)", method, path, resp.StatusCode)
  264. }
  265. var env envelope
  266. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  267. t.Fatalf("%s %s 解析响应失败: %v", method, path, err)
  268. }
  269. return &env
  270. }
  271. // newApp 按端点清单构建 fiber 应用(公开路由 + 受保护路由;受保护路由前挂 claims 中间件)。
  272. // withClaims 为 true 时向 /auth/me、/auth/logout 注入已验签 claims(模拟 T4-5 中间件写入)。
  273. func (fx *fixture) newApp(claims *token.Claims) *fiber.App {
  274. fx.t.Helper()
  275. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  276. // 公开路由
  277. app.Post("/auth/login", fx.handler.Login)
  278. app.Post("/auth/issue", fx.handler.Issue)
  279. app.Post("/auth/refresh", fx.handler.Refresh)
  280. app.Get("/auth/wechat/qrcode", fx.handler.WechatQrcode)
  281. app.Get("/auth/wechat/authorize", fx.handler.WechatAuthorize)
  282. app.Get("/auth/wechat/status", fx.handler.WechatStatus)
  283. app.Get("/auth/wechat/callback", fx.handler.WechatCallback)
  284. // 受保护路由(模拟 T4-5 中间件:把 claims 写入 c.Locals(ClaimsCtxKey))
  285. auth := app.Group("/auth")
  286. auth.Use(func(c *fiber.Ctx) error {
  287. if claims != nil {
  288. _ = c.Locals(ClaimsCtxKey, claims)
  289. }
  290. return c.Next()
  291. })
  292. auth.Get("/me", fx.handler.Me)
  293. auth.Post("/logout", fx.handler.Logout)
  294. return app
  295. }
  296. // login 调 ① 并返回 loginTicket(供 ② 用)。失败即 Fatal。
  297. func (fx *fixture) login(username, password string) string {
  298. fx.t.Helper()
  299. app := fx.newApp(nil)
  300. env := do(fx.t, app, http.MethodPost, "/auth/login",
  301. `{"username":"`+username+`","password":"`+password+`"}`, nil)
  302. if env.Code != 200 {
  303. fx.t.Fatalf("login code=%d msg=%s(期望 200)", env.Code, env.Message)
  304. }
  305. var step1 struct {
  306. LoginTicket string `json:"loginTicket"`
  307. }
  308. if err := json.Unmarshal(env.Data, &step1); err != nil {
  309. fx.t.Fatalf("解析 loginTicket 失败: %v", err)
  310. }
  311. if step1.LoginTicket == "" {
  312. fx.t.Fatal("loginTicket 为空")
  313. }
  314. return step1.LoginTicket
  315. }
  316. // ---- Login(两步第一步)----
  317. // TestLogin_Success argon2 命中:code 200,data 含 loginTicket + user + memberships。
  318. func TestLogin_Success(t *testing.T) {
  319. fx := newFixture(t)
  320. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  321. fx.memberships.add("01GAQF0Z6C1V9Y3E5H7K2M8B4T", orgmodel.Membership{
  322. MembershipID: 1, CanonicalUserID: "01GAQF0Z6C1V9Y3E5H7K2M8B4T",
  323. InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
  324. })
  325. app := fx.newApp(nil)
  326. env := do(fx.t, app, http.MethodPost, "/auth/login",
  327. `{"username":"testuser","password":"correct-password"}`, nil)
  328. if env.Code != 200 || !env.Success {
  329. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  330. }
  331. var step1 struct {
  332. LoginTicket string `json:"loginTicket"`
  333. User struct {
  334. ID string `json:"id"`
  335. PersonType string `json:"personType"`
  336. Status string `json:"status"`
  337. } `json:"user"`
  338. Memberships []struct {
  339. InstitutionID int64 `json:"institutionId"`
  340. CampusID int64 `json:"campusId"`
  341. Role string `json:"role"`
  342. } `json:"memberships"`
  343. }
  344. if err := json.Unmarshal(env.Data, &step1); err != nil {
  345. t.Fatalf("解析 data 失败: %v", err)
  346. }
  347. if step1.LoginTicket == "" {
  348. t.Fatal("loginTicket 缺失")
  349. }
  350. if len(step1.LoginTicket) != 32 {
  351. t.Fatalf("loginTicket 长度=%d(期望 32 hex)", len(step1.LoginTicket))
  352. }
  353. if step1.User.ID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
  354. t.Fatalf("user.id=%q(期望 ULID)", step1.User.ID)
  355. }
  356. if len(step1.Memberships) != 1 || step1.Memberships[0].Role != "teacher" {
  357. t.Fatalf("memberships=%+v(期望 1 条 teacher)", step1.Memberships)
  358. }
  359. }
  360. // TestLogin_WrongPassword 密码错:code 1001(统一文案,防枚举)。
  361. func TestLogin_WrongPassword(t *testing.T) {
  362. fx := newFixture(t)
  363. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  364. app := fx.newApp(nil)
  365. env := do(fx.t, app, http.MethodPost, "/auth/login",
  366. `{"username":"testuser","password":"wrong-password"}`, nil)
  367. if env.Code != apperrors.CodeUnauth {
  368. t.Fatalf("code=%d msg=%s(期望 %d 统一文案)", env.Code, env.Message, apperrors.CodeUnauth)
  369. }
  370. if env.Success {
  371. t.Fatal("success 应为 false")
  372. }
  373. }
  374. // TestLogin_UnknownUsername 未知账号:code 1001(与密码错同码,防枚举)。
  375. func TestLogin_UnknownUsername(t *testing.T) {
  376. fx := newFixture(t)
  377. app := fx.newApp(nil)
  378. env := do(fx.t, app, http.MethodPost, "/auth/login",
  379. `{"username":"ghost","password":"whatever"}`, nil)
  380. if env.Code != apperrors.CodeUnauth {
  381. t.Fatalf("code=%d(期望 %d 统一文案,防枚举)", env.Code, apperrors.CodeUnauth)
  382. }
  383. }
  384. // TestLogin_DisabledUser 禁用账号:code 1001(统一文案,不泄漏"存在但禁用")。
  385. func TestLogin_DisabledUser(t *testing.T) {
  386. fx := newFixture(t)
  387. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  388. fx.addArgon2User(uid, "testuser", "correct-password")
  389. fx.users.byUID[uid].Status = model.UserStatusDisabled
  390. app := fx.newApp(nil)
  391. env := do(fx.t, app, http.MethodPost, "/auth/login",
  392. `{"username":"testuser","password":"correct-password"}`, nil)
  393. if env.Code != apperrors.CodeUnauth {
  394. t.Fatalf("code=%d(期望 %d 统一文案)", env.Code, apperrors.CodeUnauth)
  395. }
  396. }
  397. // TestLogin_Md5Salt_LazyUpgrade 命中 md5_salt:升级 argon2 后成功(code 200)。
  398. func TestLogin_Md5Salt_LazyUpgrade(t *testing.T) {
  399. fx := newFixture(t)
  400. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  401. salt := "salty"
  402. // md5(password+salt) hex 小写(教务旧算法)
  403. d := md5Hex("correct-password" + salt)
  404. fx.users.byUID[uid] = &model.User{
  405. CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
  406. PasswordHash: &d, PasswordSalt: ptr(salt), HashVersion: ptr(model.HashVersionMd5Salt),
  407. }
  408. fx.identities.add(model.IdentityTypeUsername, "testuser", "",
  409. &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: "testuser"})
  410. fx.memberships.add(uid, orgmodel.Membership{
  411. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  412. Role: "teacher", Status: "active",
  413. })
  414. app := fx.newApp(nil)
  415. env := do(fx.t, app, http.MethodPost, "/auth/login",
  416. `{"username":"testuser","password":"correct-password"}`, nil)
  417. if env.Code != 200 {
  418. t.Fatalf("code=%d msg=%s(期望 200:惰性升级后放行)", env.Code, env.Message)
  419. }
  420. // 升级写回后 hash_version 应为 argon2
  421. if fx.users.byUID[uid].HashVersion == nil || *fx.users.byUID[uid].HashVersion != model.HashVersionArgon2 {
  422. t.Fatalf("hash_version=%v(期望升级后为 argon2)", fx.users.byUID[uid].HashVersion)
  423. }
  424. }
  425. // TestLogin_BadParam 空账号:code 2001(validator 校验失败)。
  426. func TestLogin_BadParam(t *testing.T) {
  427. fx := newFixture(t)
  428. app := fx.newApp(nil)
  429. env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"","password":"whatever"}`, nil)
  430. if env.Code != apperrors.CodeBadParam {
  431. t.Fatalf("code=%d(期望 %d 参数缺失)", env.Code, apperrors.CodeBadParam)
  432. }
  433. }
  434. // TestLogin_BadFormat 非法 JSON:code 2002(参数格式错误)。
  435. func TestLogin_BadFormat(t *testing.T) {
  436. fx := newFixture(t)
  437. app := fx.newApp(nil)
  438. env := do(fx.t, app, http.MethodPost, "/auth/login", `{bad json`, nil)
  439. if env.Code != apperrors.CodeBadFormat {
  440. t.Fatalf("code=%d(期望 %d 格式错误)", env.Code, apperrors.CodeBadFormat)
  441. }
  442. }
  443. // ---- Issue(两步第二步)----
  444. // TestIssue_Success 合法票据 + 归属作用域:code 200,data 含 accessToken + institutionId + roles。
  445. func TestIssue_Success(t *testing.T) {
  446. fx := newFixture(t)
  447. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  448. fx.addArgon2User(uid, "testuser", "correct-password")
  449. fx.memberships.add(uid, orgmodel.Membership{
  450. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  451. Role: "teacher", Status: "active",
  452. })
  453. ticket := fx.login("testuser", "correct-password")
  454. app := fx.newApp(nil)
  455. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  456. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  457. if env.Code != 200 || !env.Success {
  458. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  459. }
  460. var pair struct {
  461. AccessToken string `json:"accessToken"`
  462. RefreshToken string `json:"refreshToken"`
  463. InstitutionID int64 `json:"institutionId"`
  464. CampusID int64 `json:"campusId"`
  465. Roles []string `json:"roles"`
  466. }
  467. if err := json.Unmarshal(env.Data, &pair); err != nil {
  468. t.Fatalf("解析 data 失败: %v", err)
  469. }
  470. if pair.AccessToken == "" || pair.RefreshToken == "" {
  471. t.Fatalf("accessToken/refreshToken 缺失: %+v", pair)
  472. }
  473. if pair.InstitutionID != 11 || pair.CampusID != 3 {
  474. t.Fatalf("作用域=%d/%d(期望 11/3)", pair.InstitutionID, pair.CampusID)
  475. }
  476. if len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
  477. t.Fatalf("roles=%v(期望 [teacher])", pair.Roles)
  478. }
  479. // access 应可被本公钥验签
  480. claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", pair.AccessToken)
  481. if err != nil {
  482. t.Fatalf("验签 access 失败: %v", err)
  483. }
  484. if claims.Sub != uid {
  485. t.Fatalf("claims.Sub=%q(期望 %q)", claims.Sub, uid)
  486. }
  487. }
  488. // TestIssue_InvalidTicket 非法/不存在票据:code 1001。
  489. func TestIssue_InvalidTicket(t *testing.T) {
  490. fx := newFixture(t)
  491. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  492. app := fx.newApp(nil)
  493. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  494. `{"loginTicket":"00000000000000000000000000000000","institutionId":11,"campusId":3}`, nil)
  495. if env.Code != apperrors.CodeUnauth {
  496. t.Fatalf("code=%d(期望 %d 票据失效)", env.Code, apperrors.CodeUnauth)
  497. }
  498. }
  499. // TestIssue_ReplayTicket 同一票据二次消费:code 1001(GETDEL 原子,防重放)。
  500. func TestIssue_ReplayTicket(t *testing.T) {
  501. fx := newFixture(t)
  502. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  503. fx.addArgon2User(uid, "testuser", "correct-password")
  504. fx.memberships.add(uid, orgmodel.Membership{
  505. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  506. Role: "teacher", Status: "active",
  507. })
  508. ticket := fx.login("testuser", "correct-password")
  509. app := fx.newApp(nil)
  510. body := `{"loginTicket":"` + ticket + `","institutionId":11,"campusId":3}`
  511. if env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil); env.Code != 200 {
  512. t.Fatalf("首次消费 code=%d(期望 200)", env.Code)
  513. }
  514. // 重放:票据已被 GETDEL 消费,应失效
  515. env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil)
  516. if env.Code != apperrors.CodeUnauth {
  517. t.Fatalf("重放 code=%d(期望 %d 票据已消费)", env.Code, apperrors.CodeUnauth)
  518. }
  519. }
  520. // TestIssue_ScopeNotBelong 作用域不属于该账号:code 1003(越权)。
  521. func TestIssue_ScopeNotBelong(t *testing.T) {
  522. fx := newFixture(t)
  523. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  524. fx.addArgon2User(uid, "testuser", "correct-password")
  525. fx.memberships.add(uid, orgmodel.Membership{
  526. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  527. Role: "teacher", Status: "active",
  528. })
  529. ticket := fx.login("testuser", "correct-password")
  530. app := fx.newApp(nil)
  531. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  532. `{"loginTicket":"`+ticket+`","institutionId":99,"campusId":99}`, nil)
  533. if env.Code != apperrors.CodeForbidden {
  534. t.Fatalf("code=%d(期望 %d 作用域越权)", env.Code, apperrors.CodeForbidden)
  535. }
  536. }
  537. // TestIssue_ScopeInactive 归属已失效(inactive):code 1003。
  538. func TestIssue_ScopeInactive(t *testing.T) {
  539. fx := newFixture(t)
  540. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  541. fx.addArgon2User(uid, "testuser", "correct-password")
  542. fx.memberships.add(uid, orgmodel.Membership{
  543. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  544. Role: "teacher", Status: "inactive",
  545. })
  546. ticket := fx.login("testuser", "correct-password")
  547. app := fx.newApp(nil)
  548. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  549. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  550. if env.Code != apperrors.CodeForbidden {
  551. t.Fatalf("code=%d(期望 %d 归属失效)", env.Code, apperrors.CodeForbidden)
  552. }
  553. }
  554. // TestIssue_BadParam 票据长度非 32:code 2001。
  555. func TestIssue_BadParam(t *testing.T) {
  556. fx := newFixture(t)
  557. app := fx.newApp(nil)
  558. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  559. `{"loginTicket":"short","institutionId":11,"campusId":3}`, nil)
  560. if env.Code != apperrors.CodeBadParam {
  561. t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam)
  562. }
  563. }
  564. // ---- Me(受保护)----
  565. // TestMe_Success 已验签 claims 注入:code 200,data 含 user + roles + 作用域 + memberships。
  566. func TestMe_Success(t *testing.T) {
  567. fx := newFixture(t)
  568. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  569. fx.addArgon2User(uid, "testuser", "correct-password")
  570. fx.memberships.add(uid, orgmodel.Membership{
  571. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  572. Role: "teacher", Status: "active",
  573. })
  574. claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}
  575. app := fx.newApp(claims)
  576. env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil)
  577. if env.Code != 200 || !env.Success {
  578. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  579. }
  580. var me struct {
  581. ID string `json:"id"`
  582. Roles []string `json:"roles"`
  583. InstitutionID int64 `json:"institutionId"`
  584. CampusID int64 `json:"campusId"`
  585. Memberships []struct {
  586. Role string `json:"role"`
  587. } `json:"memberships"`
  588. }
  589. if err := json.Unmarshal(env.Data, &me); err != nil {
  590. t.Fatalf("解析 data 失败: %v", err)
  591. }
  592. if me.ID != uid {
  593. t.Fatalf("id=%q(期望 %q)", me.ID, uid)
  594. }
  595. if len(me.Memberships) != 1 || me.Memberships[0].Role != "teacher" {
  596. t.Fatalf("memberships=%+v(期望 1 条 teacher)", me.Memberships)
  597. }
  598. }
  599. // TestMe_Unauth 无 claims(中间件未注入):code 1001(未登录)。
  600. func TestMe_Unauth(t *testing.T) {
  601. fx := newFixture(t)
  602. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  603. app := fx.newApp(nil) // 无 claims
  604. env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil)
  605. if env.Code != apperrors.CodeUnauth {
  606. t.Fatalf("code=%d(期望 %d 未登录)", env.Code, apperrors.CodeUnauth)
  607. }
  608. }
  609. // ---- Refresh ----
  610. // TestRefresh_Success 合法 refresh:code 200,返回新 token 对。
  611. func TestRefresh_Success(t *testing.T) {
  612. fx := newFixture(t)
  613. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  614. fx.addArgon2User(uid, "testuser", "correct-password")
  615. fx.memberships.add(uid, orgmodel.Membership{
  616. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  617. Role: "teacher", Status: "active",
  618. })
  619. ticket := fx.login("testuser", "correct-password")
  620. app := fx.newApp(nil)
  621. issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
  622. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  623. if issueEnv.Code != 200 {
  624. t.Fatalf("issue code=%d(期望 200)", issueEnv.Code)
  625. }
  626. var pair struct {
  627. AccessToken string `json:"accessToken"`
  628. RefreshToken string `json:"refreshToken"`
  629. }
  630. _ = json.Unmarshal(issueEnv.Data, &pair)
  631. // 用旧 refresh 轮换
  632. env := do(fx.t, app, http.MethodPost, "/auth/refresh",
  633. `{"refreshToken":"`+pair.RefreshToken+`"}`, nil)
  634. if env.Code != 200 {
  635. t.Fatalf("refresh code=%d msg=%s(期望 200)", env.Code, env.Message)
  636. }
  637. }
  638. // TestRefresh_Invalid 合法格式(64 hex)但不在白名单:code 1002(登录态失效,前端跳登录)。
  639. func TestRefresh_Invalid(t *testing.T) {
  640. fx := newFixture(t)
  641. app := fx.newApp(nil)
  642. env := do(fx.t, app, http.MethodPost, "/auth/refresh",
  643. `{"refreshToken":"`+strings.Repeat("0", 64)+`"}`, nil)
  644. if env.Code != apperrors.CodeTokenExpired {
  645. t.Fatalf("code=%d(期望 %d 登录态失效)", env.Code, apperrors.CodeTokenExpired)
  646. }
  647. }
  648. // TestRefresh_BadParam refresh 长度非 48:code 2001。
  649. func TestRefresh_BadParam(t *testing.T) {
  650. fx := newFixture(t)
  651. app := fx.newApp(nil)
  652. env := do(fx.t, app, http.MethodPost, "/auth/refresh", `{"refreshToken":"too-short"}`, nil)
  653. if env.Code != apperrors.CodeBadParam {
  654. t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam)
  655. }
  656. }
  657. // ---- Logout(恒 200 幂等)----
  658. // TestLogout_Success 有 claims + refresh:恒 code 200。
  659. func TestLogout_Success(t *testing.T) {
  660. fx := newFixture(t)
  661. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  662. fx.addArgon2User(uid, "testuser", "correct-password")
  663. fx.memberships.add(uid, orgmodel.Membership{
  664. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  665. Role: "teacher", Status: "active",
  666. })
  667. ticket := fx.login("testuser", "correct-password")
  668. app := fx.newApp(nil)
  669. issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
  670. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  671. var pair struct {
  672. RefreshToken string `json:"refreshToken"`
  673. }
  674. _ = json.Unmarshal(issueEnv.Data, &pair)
  675. // 带 claims 登出(模拟已验签中间件写入)
  676. claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, ID: "some-jti"}
  677. appAuth := fx.newApp(claims)
  678. env := do(fx.t, appAuth, http.MethodPost, "/auth/logout",
  679. `{"refreshToken":"`+pair.RefreshToken+`"}`, nil)
  680. if env.Code != 200 || !env.Success {
  681. t.Fatalf("logout code=%d(期望恒 200 幂等)", env.Code)
  682. }
  683. }
  684. // TestLogout_NoClaims 无 claims:仍恒 code 200(本地态必清)。
  685. func TestLogout_NoClaims(t *testing.T) {
  686. fx := newFixture(t)
  687. app := fx.newApp(nil)
  688. env := do(fx.t, app, http.MethodPost, "/auth/logout", `{}`, nil)
  689. if env.Code != 200 {
  690. t.Fatalf("logout code=%d(期望恒 200)", env.Code)
  691. }
  692. }
  693. // ---- 微信扫码登录(状态机 + 复用两步票据)----
  694. // TestWechatQrcode_Success 生成二维码:code 200,data 含 state(32hex) + qrUrl(含 state) + campusId + expiresIn。
  695. func TestWechatQrcode_Success(t *testing.T) {
  696. fx := newFixture(t)
  697. app := fx.newApp(nil)
  698. env := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
  699. if env.Code != 200 || !env.Success {
  700. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  701. }
  702. var qr struct {
  703. State string `json:"state"`
  704. QRURL string `json:"qrUrl"`
  705. CampusID int64 `json:"campusId"`
  706. ExpiresIn int64 `json:"expiresIn"`
  707. }
  708. if err := json.Unmarshal(env.Data, &qr); err != nil {
  709. t.Fatalf("解析 data 失败: %v", err)
  710. }
  711. if len(qr.State) != 32 {
  712. t.Fatalf("state 长度=%d(期望 32 hex)", len(qr.State))
  713. }
  714. if !strings.Contains(qr.QRURL, "open.weixin.qq.com/connect/qrconnect") {
  715. t.Fatalf("qrUrl=%q(期望微信网页授权 URL)", qr.QRURL)
  716. }
  717. if !strings.Contains(qr.QRURL, "state="+qr.State) {
  718. t.Fatalf("qrUrl=%q(期望携带 state=%q)", qr.QRURL, qr.State)
  719. }
  720. if qr.CampusID != 3 {
  721. t.Fatalf("campusId=%d(期望 3,配置首个 campus)", qr.CampusID)
  722. }
  723. if qr.ExpiresIn <= 0 {
  724. t.Fatalf("expiresIn=%d(期望 >0 秒)", qr.ExpiresIn)
  725. }
  726. }
  727. // TestWechatStatus_Pending 新 state 轮询:status=pending(继续轮询)。
  728. func TestWechatStatus_Pending(t *testing.T) {
  729. fx := newFixture(t)
  730. app := fx.newApp(nil)
  731. qr := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
  732. var q struct {
  733. State string `json:"state"`
  734. }
  735. _ = json.Unmarshal(qr.Data, &q)
  736. env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+q.State, "", nil)
  737. if env.Code != 200 {
  738. t.Fatalf("code=%d msg=%s(期望 200)", env.Code, env.Message)
  739. }
  740. var st struct {
  741. Status string `json:"status"`
  742. }
  743. if err := json.Unmarshal(env.Data, &st); err != nil {
  744. t.Fatalf("解析 data 失败: %v", err)
  745. }
  746. if st.Status != "pending" {
  747. t.Fatalf("status=%q(期望 pending)", st.Status)
  748. }
  749. }
  750. // TestWechatStatus_Expired 未知 state 轮询:expired=true(前端停轮询,提示刷新)。
  751. func TestWechatStatus_Expired(t *testing.T) {
  752. fx := newFixture(t)
  753. app := fx.newApp(nil)
  754. env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state=doesnotexist00000000000000000000", "", nil)
  755. if env.Code != 200 {
  756. t.Fatalf("code=%d msg=%s(期望 200,expired 语义非故障)", env.Code, env.Message)
  757. }
  758. var st struct {
  759. Expired bool `json:"expired"`
  760. }
  761. if err := json.Unmarshal(env.Data, &st); err != nil {
  762. t.Fatalf("解析 data 失败: %v", err)
  763. }
  764. if !st.Expired {
  765. t.Fatal("未知 state 应 expired=true")
  766. }
  767. }
  768. // TestWechatCallback_Success 扫码成功(已绑定):code 换 openid → 发 loginTicket → status=success。
  769. // 校验:callback 返回 success + loginTicket + userId;state 内票据可被 ② issue 消费(复用两步②)。
  770. func TestWechatCallback_Success(t *testing.T) {
  771. fx := newFixture(t)
  772. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  773. fx.addWechatUser(uid, "openid-bound")
  774. fx.memberships.add(uid, orgmodel.Membership{
  775. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  776. Role: "teacher", Status: "active",
  777. })
  778. fx.wechat.byCode["code-ok"] = "openid-bound"
  779. app := fx.newApp(nil)
  780. // ① 生成二维码
  781. qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
  782. var qr struct {
  783. State string `json:"state"`
  784. }
  785. _ = json.Unmarshal(qrEnv.Data, &qr)
  786. // ② 微信回调:code=code-ok, state=<state>
  787. cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil)
  788. if cbEnv.Code != 200 {
  789. t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message)
  790. }
  791. var cb struct {
  792. Status string `json:"status"`
  793. LoginTicket string `json:"loginTicket"`
  794. UserID string `json:"userId"`
  795. Memberships []struct {
  796. InstitutionID int64 `json:"institutionId"`
  797. CampusID int64 `json:"campusId"`
  798. Role string `json:"role"`
  799. } `json:"memberships"`
  800. }
  801. if err := json.Unmarshal(cbEnv.Data, &cb); err != nil {
  802. t.Fatalf("解析 callback data 失败: %v", err)
  803. }
  804. if cb.Status != "success" {
  805. t.Fatalf("status=%q(期望 success)", cb.Status)
  806. }
  807. if cb.UserID != uid {
  808. t.Fatalf("userId=%q(期望 %q)", cb.UserID, uid)
  809. }
  810. if cb.LoginTicket == "" || len(cb.LoginTicket) != 32 {
  811. t.Fatalf("loginTicket=%q(期望 32 hex)", cb.LoginTicket)
  812. }
  813. // success 应回显可选作用域列表(与账密登录 LoginStep1 同源;前端据此走同一套 ② 分流)
  814. if len(cb.Memberships) != 1 || cb.Memberships[0].Role != "teacher" || cb.Memberships[0].InstitutionID != 11 {
  815. t.Fatalf("memberships=%+v(期望 1 条 inst=11 role=teacher)", cb.Memberships)
  816. }
  817. // ③ 轮询确认 state=success 且携带同一 loginTicket
  818. stEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+qr.State, "", nil)
  819. var st struct {
  820. Status string `json:"status"`
  821. LoginTicket string `json:"loginTicket"`
  822. }
  823. _ = json.Unmarshal(stEnv.Data, &st)
  824. if st.Status != "success" || st.LoginTicket != cb.LoginTicket {
  825. t.Fatalf("轮询 status=%q ticket=%q(期望 success + 同票据)", st.Status, st.LoginTicket)
  826. }
  827. // ④ 复用两步②:携 loginTicket 签 token(证明微信登录与账密登录走同一套 ②)
  828. issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
  829. `{"loginTicket":"`+cb.LoginTicket+`","institutionId":11,"campusId":3}`, nil)
  830. if issueEnv.Code != 200 {
  831. t.Fatalf("issue code=%d msg=%s(期望 200:微信票据应可走同一套 ②)", issueEnv.Code, issueEnv.Message)
  832. }
  833. }
  834. // TestWechatCallback_Unbound 扫码成功但 openid 未绑定:status=unbound(前端提示"未绑定账号")。
  835. func TestWechatCallback_Unbound(t *testing.T) {
  836. fx := newFixture(t)
  837. fx.wechat.byCode["code-x"] = "openid-not-bound" // 不造该 openid 的身份记录
  838. app := fx.newApp(nil)
  839. qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
  840. var qr struct {
  841. State string `json:"state"`
  842. }
  843. _ = json.Unmarshal(qrEnv.Data, &qr)
  844. cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-x&state="+qr.State, "", nil)
  845. if cbEnv.Code != 200 {
  846. t.Fatalf("callback code=%d msg=%s(期望 200,unbound 语义)", cbEnv.Code, cbEnv.Message)
  847. }
  848. var cb struct {
  849. Status string `json:"status"`
  850. }
  851. _ = json.Unmarshal(cbEnv.Data, &cb)
  852. if cb.Status != "unbound" {
  853. t.Fatalf("status=%q(期望 unbound)", cb.Status)
  854. }
  855. }
  856. // TestWechatCallback_ThirdPartyFail 微信换 openid 失败:status=fail + code 4003(第三方超时/失败)。
  857. func TestWechatCallback_ThirdPartyFail(t *testing.T) {
  858. fx := newFixture(t)
  859. fx.wechat.fail = true // 打桩:换 openid 失败
  860. app := fx.newApp(nil)
  861. qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
  862. var qr struct {
  863. State string `json:"state"`
  864. }
  865. _ = json.Unmarshal(qrEnv.Data, &qr)
  866. cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=bad&state="+qr.State, "", nil)
  867. if cbEnv.Code != apperrors.CodeThirdTimeout {
  868. t.Fatalf("callback code=%d(期望 %d 第三方失败)", cbEnv.Code, apperrors.CodeThirdTimeout)
  869. }
  870. // state 应被置 fail(前端后续轮询读到 fail)
  871. stEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+qr.State, "", nil)
  872. var st struct {
  873. Status string `json:"status"`
  874. }
  875. _ = json.Unmarshal(stEnv.Data, &st)
  876. if st.Status != "fail" {
  877. t.Fatalf("state 应置 fail,实际 %q", st.Status)
  878. }
  879. }
  880. // TestWechatCallback_Replay 已终态 state 二次回调:code 1001(防重放)。
  881. func TestWechatCallback_Replay(t *testing.T) {
  882. fx := newFixture(t)
  883. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  884. fx.addWechatUser(uid, "openid-bound")
  885. fx.wechat.byCode["code-ok"] = "openid-bound"
  886. app := fx.newApp(nil)
  887. qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
  888. var qr struct {
  889. State string `json:"state"`
  890. }
  891. _ = json.Unmarshal(qrEnv.Data, &qr)
  892. // 首次回调 → success(终态)
  893. if env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil); env.Code != 200 {
  894. t.Fatalf("首次回调 code=%d(期望 200)", env.Code)
  895. }
  896. // 二次回同样 state → 1001(已终态,防重放)
  897. env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil)
  898. if env.Code != apperrors.CodeUnauth {
  899. t.Fatalf("重放 callback code=%d(期望 %d 已终态)", env.Code, apperrors.CodeUnauth)
  900. }
  901. }
  902. // TestWechatCallback_MissingParam 缺 code 或 state:code 2001(参数校验)。
  903. func TestWechatCallback_MissingParam(t *testing.T) {
  904. fx := newFixture(t)
  905. app := fx.newApp(nil)
  906. env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=only-code", "", nil)
  907. if env.Code != apperrors.CodeBadParam {
  908. t.Fatalf("code=%d(期望 %d 缺 state 参数)", env.Code, apperrors.CodeBadParam)
  909. }
  910. }
  911. // TestWechat_NotConfigured wechat 服务未注入:qrcode/status/callback 均 code 4004(未配置)。
  912. func TestWechat_NotConfigured(t *testing.T) {
  913. fx := newFixture(t)
  914. // 构造一个 wechat 为 nil 的 handler(复用 fx.svc 之外的 AuthService,此处直接重造 Handler)
  915. svc := fx.handler.svc // 复用现有 svc
  916. h, err := New(Config{Svc: svc}) // Wechat 缺省 nil
  917. if err != nil {
  918. t.Fatalf("New Handler: %v", err)
  919. }
  920. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  921. app.Get("/auth/wechat/qrcode", h.WechatQrcode)
  922. app.Get("/auth/wechat/authorize", h.WechatAuthorize)
  923. app.Get("/auth/wechat/status", h.WechatStatus)
  924. app.Get("/auth/wechat/callback", h.WechatCallback)
  925. for _, path := range []string{
  926. "/auth/wechat/qrcode",
  927. "/auth/wechat/authorize",
  928. "/auth/wechat/status?state=x",
  929. "/auth/wechat/callback?code=x&state=x",
  930. } {
  931. env := do(fx.t, app, http.MethodGet, path, "", nil)
  932. if env.Code != apperrors.CodeThirdNotConfigured {
  933. t.Fatalf("%s code=%d(期望 %d 未配置)", path, env.Code, apperrors.CodeThirdNotConfigured)
  934. }
  935. }
  936. }
  937. // ---- 移动端 App 内微信授权(webview,snsapi_userinfo)----
  938. // TestWechatAuthorize_Success 生成授权 URL:code 200,data 含 state(32hex) + authorizeUrl(oauth2/authorize + snsapi_userinfo)
  939. // + campusId + expiresIn。authorizeUrl 与 qrUrl 的差异:endpoint=oauth2/authorize(非 qrconnect)、
  940. // scope=snsapi_userinfo(非 snsapi_login)、**无** #wechat_redirect 尾段。
  941. func TestWechatAuthorize_Success(t *testing.T) {
  942. fx := newFixture(t)
  943. app := fx.newApp(nil)
  944. env := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil)
  945. if env.Code != 200 || !env.Success {
  946. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  947. }
  948. var ar struct {
  949. State string `json:"state"`
  950. AuthorizeURL string `json:"authorizeUrl"`
  951. CampusID int64 `json:"campusId"`
  952. ExpiresIn int64 `json:"expiresIn"`
  953. }
  954. if err := json.Unmarshal(env.Data, &ar); err != nil {
  955. t.Fatalf("解析 data 失败: %v", err)
  956. }
  957. if len(ar.State) != 32 {
  958. t.Fatalf("state 长度=%d(期望 32 hex)", len(ar.State))
  959. }
  960. // endpoint 应为 oauth2/authorize(非 qrconnect)
  961. if !strings.Contains(ar.AuthorizeURL, "open.weixin.qq.com/connect/oauth2/authorize") {
  962. t.Fatalf("authorizeUrl=%q(期望 connect/oauth2/authorize)", ar.AuthorizeURL)
  963. }
  964. // scope 应为 snsapi_userinfo(非 snsapi_login)
  965. if !strings.Contains(ar.AuthorizeURL, "scope=snsapi_userinfo") {
  966. t.Fatalf("authorizeUrl=%q(期望 scope=snsapi_userinfo)", ar.AuthorizeURL)
  967. }
  968. // 无 #wechat_redirect 尾段(webview 场景不保留)
  969. if strings.Contains(ar.AuthorizeURL, "#wechat_redirect") {
  970. t.Fatalf("authorizeUrl=%q(webview 场景不应含 #wechat_redirect)", ar.AuthorizeURL)
  971. }
  972. if !strings.Contains(ar.AuthorizeURL, "state="+ar.State) {
  973. t.Fatalf("authorizeUrl=%q(期望携带 state=%q)", ar.AuthorizeURL, ar.State)
  974. }
  975. if ar.CampusID != 3 {
  976. t.Fatalf("campusId=%d(期望 3,配置首个 campus)", ar.CampusID)
  977. }
  978. if ar.ExpiresIn <= 0 {
  979. t.Fatalf("expiresIn=%d(期望 >0 秒)", ar.ExpiresIn)
  980. }
  981. }
  982. // TestWechatAuthorize_ReuseCallback 授权 URL 的 state 可被同一 /auth/wechat/callback 消费
  983. // (证明 PC 扫码与移动 webview 共用同一 callback 换 openid,无需独立端点)。
  984. func TestWechatAuthorize_ReuseCallback(t *testing.T) {
  985. fx := newFixture(t)
  986. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  987. fx.addWechatUser(uid, "openid-mobile")
  988. fx.memberships.add(uid, orgmodel.Membership{
  989. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  990. Role: "teacher", Status: "active",
  991. })
  992. fx.wechat.byCode["code-mobile"] = "openid-mobile"
  993. app := fx.newApp(nil)
  994. // ① 生成移动 webview 授权 URL
  995. authEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil)
  996. var ar struct {
  997. State string `json:"state"`
  998. }
  999. _ = json.Unmarshal(authEnv.Data, &ar)
  1000. // ② 模拟微信重定向回 callback(同 PC 扫码的同一端点)
  1001. cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-mobile&state="+ar.State, "", nil)
  1002. if cbEnv.Code != 200 {
  1003. t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message)
  1004. }
  1005. var cb struct {
  1006. Status string `json:"status"`
  1007. LoginTicket string `json:"loginTicket"`
  1008. }
  1009. _ = json.Unmarshal(cbEnv.Data, &cb)
  1010. if cb.Status != "success" || cb.LoginTicket == "" {
  1011. t.Fatalf("status=%q ticket=%q(期望 success + 票据)", cb.Status, cb.LoginTicket)
  1012. }
  1013. }
  1014. // md5Hex 计算 md5 的 hex 小写串(构造 md5_salt 测试夹具用,与 credential.legacyDigest 同语义)。
  1015. func md5Hex(s string) string {
  1016. d := md5.Sum([]byte(s))
  1017. return hex.EncodeToString(d[:])
  1018. }