handler_test.go 40 KB

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