handler_test.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  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. type fixture struct {
  111. t *testing.T
  112. users *mockUsers
  113. identities *mockIdentities
  114. memberships *mockMemberships
  115. handler *Handler
  116. pubKeys []string
  117. privPEM string
  118. issuer *token.Issuer
  119. revoke *appredis.RevocationStore
  120. refresh *appredis.RefreshStore
  121. }
  122. // newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService + Handler。
  123. func newFixture(t *testing.T) *fixture {
  124. t.Helper()
  125. mr, err := miniredis.Run()
  126. if err != nil {
  127. t.Fatalf("miniredis.Run: %v", err)
  128. }
  129. t.Cleanup(mr.Close)
  130. rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()})
  131. if err != nil {
  132. t.Fatalf("NewClient: %v", err)
  133. }
  134. t.Cleanup(func() { _ = rc.Close() })
  135. privPEM, pubKeys := genRSAKey(t)
  136. refreshStore := appredis.NewRefreshStore(rc)
  137. revocation := appredis.NewRevocationStore(rc, "revoked")
  138. tickets := appredis.NewLoginTicketStore(rc)
  139. issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation)
  140. if err != nil {
  141. t.Fatalf("NewIssuer: %v", err)
  142. }
  143. users := &mockUsers{byUID: map[string]*model.User{}}
  144. ids := newMockIdentities()
  145. mergs := &mockMemberships{byUser: map[string][]orgmodel.Membership{}}
  146. svc, err := service.NewAuthService(service.Config{
  147. Users: users, Identities: ids, Memberships: mergs,
  148. Issuer: issuer, Tickets: tickets,
  149. AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test",
  150. Revocation: revocation, LoginTicketTTL: 5 * time.Minute,
  151. })
  152. if err != nil {
  153. t.Fatalf("NewAuthService: %v", err)
  154. }
  155. h, err := New(Config{Svc: svc})
  156. if err != nil {
  157. t.Fatalf("New Handler: %v", err)
  158. }
  159. return &fixture{
  160. t: t, users: users, identities: ids, memberships: mergs, handler: h,
  161. pubKeys: pubKeys, privPEM: privPEM, issuer: issuer, revoke: revocation, refresh: refreshStore,
  162. }
  163. }
  164. // genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。
  165. func genRSAKey(t *testing.T) (string, []string) {
  166. t.Helper()
  167. key, err := rsa.GenerateKey(rand.Reader, 2048)
  168. if err != nil {
  169. t.Fatalf("生成 RSA 私钥: %v", err)
  170. }
  171. privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}))
  172. pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
  173. if err != nil {
  174. t.Fatalf("序列化公钥: %v", err)
  175. }
  176. pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
  177. return privPEM, []string{pubPEM}
  178. }
  179. // addUser 造一个 argon2 用户 + username 身份(测试夹具快捷构造)。
  180. func (fx *fixture) addArgon2User(uid, username, password string) {
  181. fx.t.Helper()
  182. hash, err := credential.HashArgon2(password)
  183. if err != nil {
  184. fx.t.Fatalf("HashArgon2: %v", err)
  185. }
  186. fx.users.byUID[uid] = &model.User{
  187. CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
  188. PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2),
  189. }
  190. fx.identities.add(model.IdentityTypeUsername, username,
  191. &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username})
  192. }
  193. func ptr[T any](v T) *T { return &v }
  194. // ---- 响应解析 ----
  195. // envelope 统一响应信封(断言用;Data 留原始 JSON 供按需再解析)。
  196. type envelope struct {
  197. Code int `json:"code"`
  198. Message string `json:"message"`
  199. Data json.RawMessage `json:"data"`
  200. Success bool `json:"success"`
  201. }
  202. // do 发一个请求并解出统一信封(HTTP 恒 200,断 body.code)。
  203. func do(t *testing.T, app *fiber.App, method, path, body string, headers map[string]string) *envelope {
  204. t.Helper()
  205. var req *http.Request
  206. if method == http.MethodGet {
  207. req = httptest.NewRequest(method, path, nil)
  208. } else {
  209. req = httptest.NewRequest(method, path, strings.NewReader(body))
  210. }
  211. for k, v := range headers {
  212. req.Header.Set(k, v)
  213. }
  214. resp, err := app.Test(req, -1)
  215. if err != nil {
  216. t.Fatalf("%s %s 请求失败: %v", method, path, err)
  217. }
  218. if resp.StatusCode != http.StatusOK {
  219. t.Fatalf("%s %s HTTP 状态码=%d(铁律 6 要求恒 200)", method, path, resp.StatusCode)
  220. }
  221. var env envelope
  222. if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
  223. t.Fatalf("%s %s 解析响应失败: %v", method, path, err)
  224. }
  225. return &env
  226. }
  227. // newApp 按端点清单构建 fiber 应用(公开路由 + 受保护路由;受保护路由前挂 claims 中间件)。
  228. // withClaims 为 true 时向 /auth/me、/auth/logout 注入已验签 claims(模拟 T4-5 中间件写入)。
  229. func (fx *fixture) newApp(claims *token.Claims) *fiber.App {
  230. fx.t.Helper()
  231. app := fiber.New(fiber.Config{DisableStartupMessage: true})
  232. // 公开路由
  233. app.Post("/auth/login", fx.handler.Login)
  234. app.Post("/auth/issue", fx.handler.Issue)
  235. app.Post("/auth/refresh", fx.handler.Refresh)
  236. app.Get("/auth/wechat/qrcode", fx.handler.WechatQrcode)
  237. app.Get("/auth/wechat/status", fx.handler.WechatStatus)
  238. // 受保护路由(模拟 T4-5 中间件:把 claims 写入 c.Locals(ClaimsCtxKey))
  239. auth := app.Group("/auth")
  240. auth.Use(func(c *fiber.Ctx) error {
  241. if claims != nil {
  242. _ = c.Locals(ClaimsCtxKey, claims)
  243. }
  244. return c.Next()
  245. })
  246. auth.Get("/me", fx.handler.Me)
  247. auth.Post("/logout", fx.handler.Logout)
  248. return app
  249. }
  250. // login 调 ① 并返回 loginTicket(供 ② 用)。失败即 Fatal。
  251. func (fx *fixture) login(username, password string) string {
  252. fx.t.Helper()
  253. app := fx.newApp(nil)
  254. env := do(fx.t, app, http.MethodPost, "/auth/login",
  255. `{"username":"`+username+`","password":"`+password+`"}`, nil)
  256. if env.Code != 200 {
  257. fx.t.Fatalf("login code=%d msg=%s(期望 200)", env.Code, env.Message)
  258. }
  259. var step1 struct {
  260. LoginTicket string `json:"loginTicket"`
  261. }
  262. if err := json.Unmarshal(env.Data, &step1); err != nil {
  263. fx.t.Fatalf("解析 loginTicket 失败: %v", err)
  264. }
  265. if step1.LoginTicket == "" {
  266. fx.t.Fatal("loginTicket 为空")
  267. }
  268. return step1.LoginTicket
  269. }
  270. // ---- Login(两步第一步)----
  271. // TestLogin_Success argon2 命中:code 200,data 含 loginTicket + user + memberships。
  272. func TestLogin_Success(t *testing.T) {
  273. fx := newFixture(t)
  274. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  275. fx.memberships.add("01GAQF0Z6C1V9Y3E5H7K2M8B4T", orgmodel.Membership{
  276. MembershipID: 1, CanonicalUserID: "01GAQF0Z6C1V9Y3E5H7K2M8B4T",
  277. InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active",
  278. })
  279. app := fx.newApp(nil)
  280. env := do(fx.t, app, http.MethodPost, "/auth/login",
  281. `{"username":"testuser","password":"correct-password"}`, nil)
  282. if env.Code != 200 || !env.Success {
  283. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  284. }
  285. var step1 struct {
  286. LoginTicket string `json:"loginTicket"`
  287. User struct {
  288. ID string `json:"id"`
  289. PersonType string `json:"personType"`
  290. Status string `json:"status"`
  291. } `json:"user"`
  292. Memberships []struct {
  293. InstitutionID int64 `json:"institutionId"`
  294. CampusID int64 `json:"campusId"`
  295. Role string `json:"role"`
  296. } `json:"memberships"`
  297. }
  298. if err := json.Unmarshal(env.Data, &step1); err != nil {
  299. t.Fatalf("解析 data 失败: %v", err)
  300. }
  301. if step1.LoginTicket == "" {
  302. t.Fatal("loginTicket 缺失")
  303. }
  304. if len(step1.LoginTicket) != 32 {
  305. t.Fatalf("loginTicket 长度=%d(期望 32 hex)", len(step1.LoginTicket))
  306. }
  307. if step1.User.ID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" {
  308. t.Fatalf("user.id=%q(期望 ULID)", step1.User.ID)
  309. }
  310. if len(step1.Memberships) != 1 || step1.Memberships[0].Role != "teacher" {
  311. t.Fatalf("memberships=%+v(期望 1 条 teacher)", step1.Memberships)
  312. }
  313. }
  314. // TestLogin_WrongPassword 密码错:code 1001(统一文案,防枚举)。
  315. func TestLogin_WrongPassword(t *testing.T) {
  316. fx := newFixture(t)
  317. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  318. app := fx.newApp(nil)
  319. env := do(fx.t, app, http.MethodPost, "/auth/login",
  320. `{"username":"testuser","password":"wrong-password"}`, nil)
  321. if env.Code != apperrors.CodeUnauth {
  322. t.Fatalf("code=%d msg=%s(期望 %d 统一文案)", env.Code, env.Message, apperrors.CodeUnauth)
  323. }
  324. if env.Success {
  325. t.Fatal("success 应为 false")
  326. }
  327. }
  328. // TestLogin_UnknownUsername 未知账号:code 1001(与密码错同码,防枚举)。
  329. func TestLogin_UnknownUsername(t *testing.T) {
  330. fx := newFixture(t)
  331. app := fx.newApp(nil)
  332. env := do(fx.t, app, http.MethodPost, "/auth/login",
  333. `{"username":"ghost","password":"whatever"}`, nil)
  334. if env.Code != apperrors.CodeUnauth {
  335. t.Fatalf("code=%d(期望 %d 统一文案,防枚举)", env.Code, apperrors.CodeUnauth)
  336. }
  337. }
  338. // TestLogin_DisabledUser 禁用账号:code 1001(统一文案,不泄漏"存在但禁用")。
  339. func TestLogin_DisabledUser(t *testing.T) {
  340. fx := newFixture(t)
  341. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  342. fx.addArgon2User(uid, "testuser", "correct-password")
  343. fx.users.byUID[uid].Status = model.UserStatusDisabled
  344. app := fx.newApp(nil)
  345. env := do(fx.t, app, http.MethodPost, "/auth/login",
  346. `{"username":"testuser","password":"correct-password"}`, nil)
  347. if env.Code != apperrors.CodeUnauth {
  348. t.Fatalf("code=%d(期望 %d 统一文案)", env.Code, apperrors.CodeUnauth)
  349. }
  350. }
  351. // TestLogin_Md5Salt_LazyUpgrade 命中 md5_salt:升级 argon2 后成功(code 200)。
  352. func TestLogin_Md5Salt_LazyUpgrade(t *testing.T) {
  353. fx := newFixture(t)
  354. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  355. salt := "salty"
  356. // md5(password+salt) hex 小写(教务旧算法)
  357. d := md5Hex("correct-password" + salt)
  358. fx.users.byUID[uid] = &model.User{
  359. CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive,
  360. PasswordHash: &d, PasswordSalt: ptr(salt), HashVersion: ptr(model.HashVersionMd5Salt),
  361. }
  362. fx.identities.add(model.IdentityTypeUsername, "testuser",
  363. &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: "testuser"})
  364. fx.memberships.add(uid, orgmodel.Membership{
  365. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  366. Role: "teacher", Status: "active",
  367. })
  368. app := fx.newApp(nil)
  369. env := do(fx.t, app, http.MethodPost, "/auth/login",
  370. `{"username":"testuser","password":"correct-password"}`, nil)
  371. if env.Code != 200 {
  372. t.Fatalf("code=%d msg=%s(期望 200:惰性升级后放行)", env.Code, env.Message)
  373. }
  374. // 升级写回后 hash_version 应为 argon2
  375. if fx.users.byUID[uid].HashVersion == nil || *fx.users.byUID[uid].HashVersion != model.HashVersionArgon2 {
  376. t.Fatalf("hash_version=%v(期望升级后为 argon2)", fx.users.byUID[uid].HashVersion)
  377. }
  378. }
  379. // TestLogin_BadParam 空账号:code 2001(validator 校验失败)。
  380. func TestLogin_BadParam(t *testing.T) {
  381. fx := newFixture(t)
  382. app := fx.newApp(nil)
  383. env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"","password":"whatever"}`, nil)
  384. if env.Code != apperrors.CodeBadParam {
  385. t.Fatalf("code=%d(期望 %d 参数缺失)", env.Code, apperrors.CodeBadParam)
  386. }
  387. }
  388. // TestLogin_BadFormat 非法 JSON:code 2002(参数格式错误)。
  389. func TestLogin_BadFormat(t *testing.T) {
  390. fx := newFixture(t)
  391. app := fx.newApp(nil)
  392. env := do(fx.t, app, http.MethodPost, "/auth/login", `{bad json`, nil)
  393. if env.Code != apperrors.CodeBadFormat {
  394. t.Fatalf("code=%d(期望 %d 格式错误)", env.Code, apperrors.CodeBadFormat)
  395. }
  396. }
  397. // ---- Issue(两步第二步)----
  398. // TestIssue_Success 合法票据 + 归属作用域:code 200,data 含 accessToken + institutionId + roles。
  399. func TestIssue_Success(t *testing.T) {
  400. fx := newFixture(t)
  401. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  402. fx.addArgon2User(uid, "testuser", "correct-password")
  403. fx.memberships.add(uid, orgmodel.Membership{
  404. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  405. Role: "teacher", Status: "active",
  406. })
  407. ticket := fx.login("testuser", "correct-password")
  408. app := fx.newApp(nil)
  409. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  410. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  411. if env.Code != 200 || !env.Success {
  412. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  413. }
  414. var pair struct {
  415. AccessToken string `json:"accessToken"`
  416. RefreshToken string `json:"refreshToken"`
  417. InstitutionID int64 `json:"institutionId"`
  418. CampusID int64 `json:"campusId"`
  419. Roles []string `json:"roles"`
  420. }
  421. if err := json.Unmarshal(env.Data, &pair); err != nil {
  422. t.Fatalf("解析 data 失败: %v", err)
  423. }
  424. if pair.AccessToken == "" || pair.RefreshToken == "" {
  425. t.Fatalf("accessToken/refreshToken 缺失: %+v", pair)
  426. }
  427. if pair.InstitutionID != 11 || pair.CampusID != 3 {
  428. t.Fatalf("作用域=%d/%d(期望 11/3)", pair.InstitutionID, pair.CampusID)
  429. }
  430. if len(pair.Roles) != 1 || pair.Roles[0] != "teacher" {
  431. t.Fatalf("roles=%v(期望 [teacher])", pair.Roles)
  432. }
  433. // access 应可被本公钥验签
  434. claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", pair.AccessToken)
  435. if err != nil {
  436. t.Fatalf("验签 access 失败: %v", err)
  437. }
  438. if claims.Sub != uid {
  439. t.Fatalf("claims.Sub=%q(期望 %q)", claims.Sub, uid)
  440. }
  441. }
  442. // TestIssue_InvalidTicket 非法/不存在票据:code 1001。
  443. func TestIssue_InvalidTicket(t *testing.T) {
  444. fx := newFixture(t)
  445. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  446. app := fx.newApp(nil)
  447. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  448. `{"loginTicket":"00000000000000000000000000000000","institutionId":11,"campusId":3}`, nil)
  449. if env.Code != apperrors.CodeUnauth {
  450. t.Fatalf("code=%d(期望 %d 票据失效)", env.Code, apperrors.CodeUnauth)
  451. }
  452. }
  453. // TestIssue_ReplayTicket 同一票据二次消费:code 1001(GETDEL 原子,防重放)。
  454. func TestIssue_ReplayTicket(t *testing.T) {
  455. fx := newFixture(t)
  456. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  457. fx.addArgon2User(uid, "testuser", "correct-password")
  458. fx.memberships.add(uid, orgmodel.Membership{
  459. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  460. Role: "teacher", Status: "active",
  461. })
  462. ticket := fx.login("testuser", "correct-password")
  463. app := fx.newApp(nil)
  464. body := `{"loginTicket":"` + ticket + `","institutionId":11,"campusId":3}`
  465. if env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil); env.Code != 200 {
  466. t.Fatalf("首次消费 code=%d(期望 200)", env.Code)
  467. }
  468. // 重放:票据已被 GETDEL 消费,应失效
  469. env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil)
  470. if env.Code != apperrors.CodeUnauth {
  471. t.Fatalf("重放 code=%d(期望 %d 票据已消费)", env.Code, apperrors.CodeUnauth)
  472. }
  473. }
  474. // TestIssue_ScopeNotBelong 作用域不属于该账号:code 1003(越权)。
  475. func TestIssue_ScopeNotBelong(t *testing.T) {
  476. fx := newFixture(t)
  477. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  478. fx.addArgon2User(uid, "testuser", "correct-password")
  479. fx.memberships.add(uid, orgmodel.Membership{
  480. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  481. Role: "teacher", Status: "active",
  482. })
  483. ticket := fx.login("testuser", "correct-password")
  484. app := fx.newApp(nil)
  485. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  486. `{"loginTicket":"`+ticket+`","institutionId":99,"campusId":99}`, nil)
  487. if env.Code != apperrors.CodeForbidden {
  488. t.Fatalf("code=%d(期望 %d 作用域越权)", env.Code, apperrors.CodeForbidden)
  489. }
  490. }
  491. // TestIssue_ScopeInactive 归属已失效(inactive):code 1003。
  492. func TestIssue_ScopeInactive(t *testing.T) {
  493. fx := newFixture(t)
  494. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  495. fx.addArgon2User(uid, "testuser", "correct-password")
  496. fx.memberships.add(uid, orgmodel.Membership{
  497. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  498. Role: "teacher", Status: "inactive",
  499. })
  500. ticket := fx.login("testuser", "correct-password")
  501. app := fx.newApp(nil)
  502. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  503. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  504. if env.Code != apperrors.CodeForbidden {
  505. t.Fatalf("code=%d(期望 %d 归属失效)", env.Code, apperrors.CodeForbidden)
  506. }
  507. }
  508. // TestIssue_BadParam 票据长度非 32:code 2001。
  509. func TestIssue_BadParam(t *testing.T) {
  510. fx := newFixture(t)
  511. app := fx.newApp(nil)
  512. env := do(fx.t, app, http.MethodPost, "/auth/issue",
  513. `{"loginTicket":"short","institutionId":11,"campusId":3}`, nil)
  514. if env.Code != apperrors.CodeBadParam {
  515. t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam)
  516. }
  517. }
  518. // ---- Me(受保护)----
  519. // TestMe_Success 已验签 claims 注入:code 200,data 含 user + roles + 作用域 + memberships。
  520. func TestMe_Success(t *testing.T) {
  521. fx := newFixture(t)
  522. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  523. fx.addArgon2User(uid, "testuser", "correct-password")
  524. fx.memberships.add(uid, orgmodel.Membership{
  525. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  526. Role: "teacher", Status: "active",
  527. })
  528. claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}
  529. app := fx.newApp(claims)
  530. env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil)
  531. if env.Code != 200 || !env.Success {
  532. t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message)
  533. }
  534. var me struct {
  535. ID string `json:"id"`
  536. Roles []string `json:"roles"`
  537. InstitutionID int64 `json:"institutionId"`
  538. CampusID int64 `json:"campusId"`
  539. Memberships []struct {
  540. Role string `json:"role"`
  541. } `json:"memberships"`
  542. }
  543. if err := json.Unmarshal(env.Data, &me); err != nil {
  544. t.Fatalf("解析 data 失败: %v", err)
  545. }
  546. if me.ID != uid {
  547. t.Fatalf("id=%q(期望 %q)", me.ID, uid)
  548. }
  549. if len(me.Memberships) != 1 || me.Memberships[0].Role != "teacher" {
  550. t.Fatalf("memberships=%+v(期望 1 条 teacher)", me.Memberships)
  551. }
  552. }
  553. // TestMe_Unauth 无 claims(中间件未注入):code 1001(未登录)。
  554. func TestMe_Unauth(t *testing.T) {
  555. fx := newFixture(t)
  556. fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password")
  557. app := fx.newApp(nil) // 无 claims
  558. env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil)
  559. if env.Code != apperrors.CodeUnauth {
  560. t.Fatalf("code=%d(期望 %d 未登录)", env.Code, apperrors.CodeUnauth)
  561. }
  562. }
  563. // ---- Refresh ----
  564. // TestRefresh_Success 合法 refresh:code 200,返回新 token 对。
  565. func TestRefresh_Success(t *testing.T) {
  566. fx := newFixture(t)
  567. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  568. fx.addArgon2User(uid, "testuser", "correct-password")
  569. fx.memberships.add(uid, orgmodel.Membership{
  570. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  571. Role: "teacher", Status: "active",
  572. })
  573. ticket := fx.login("testuser", "correct-password")
  574. app := fx.newApp(nil)
  575. issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
  576. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  577. if issueEnv.Code != 200 {
  578. t.Fatalf("issue code=%d(期望 200)", issueEnv.Code)
  579. }
  580. var pair struct {
  581. AccessToken string `json:"accessToken"`
  582. RefreshToken string `json:"refreshToken"`
  583. }
  584. _ = json.Unmarshal(issueEnv.Data, &pair)
  585. // 用旧 refresh 轮换
  586. env := do(fx.t, app, http.MethodPost, "/auth/refresh",
  587. `{"refreshToken":"`+pair.RefreshToken+`"}`, nil)
  588. if env.Code != 200 {
  589. t.Fatalf("refresh code=%d msg=%s(期望 200)", env.Code, env.Message)
  590. }
  591. }
  592. // TestRefresh_Invalid 合法格式(64 hex)但不在白名单:code 1002(登录态失效,前端跳登录)。
  593. func TestRefresh_Invalid(t *testing.T) {
  594. fx := newFixture(t)
  595. app := fx.newApp(nil)
  596. env := do(fx.t, app, http.MethodPost, "/auth/refresh",
  597. `{"refreshToken":"`+strings.Repeat("0", 64)+`"}`, nil)
  598. if env.Code != apperrors.CodeTokenExpired {
  599. t.Fatalf("code=%d(期望 %d 登录态失效)", env.Code, apperrors.CodeTokenExpired)
  600. }
  601. }
  602. // TestRefresh_BadParam refresh 长度非 48:code 2001。
  603. func TestRefresh_BadParam(t *testing.T) {
  604. fx := newFixture(t)
  605. app := fx.newApp(nil)
  606. env := do(fx.t, app, http.MethodPost, "/auth/refresh", `{"refreshToken":"too-short"}`, nil)
  607. if env.Code != apperrors.CodeBadParam {
  608. t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam)
  609. }
  610. }
  611. // ---- Logout(恒 200 幂等)----
  612. // TestLogout_Success 有 claims + refresh:恒 code 200。
  613. func TestLogout_Success(t *testing.T) {
  614. fx := newFixture(t)
  615. uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T"
  616. fx.addArgon2User(uid, "testuser", "correct-password")
  617. fx.memberships.add(uid, orgmodel.Membership{
  618. MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3,
  619. Role: "teacher", Status: "active",
  620. })
  621. ticket := fx.login("testuser", "correct-password")
  622. app := fx.newApp(nil)
  623. issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue",
  624. `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil)
  625. var pair struct {
  626. RefreshToken string `json:"refreshToken"`
  627. }
  628. _ = json.Unmarshal(issueEnv.Data, &pair)
  629. // 带 claims 登出(模拟已验签中间件写入)
  630. claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, ID: "some-jti"}
  631. appAuth := fx.newApp(claims)
  632. env := do(fx.t, appAuth, http.MethodPost, "/auth/logout",
  633. `{"refreshToken":"`+pair.RefreshToken+`"}`, nil)
  634. if env.Code != 200 || !env.Success {
  635. t.Fatalf("logout code=%d(期望恒 200 幂等)", env.Code)
  636. }
  637. }
  638. // TestLogout_NoClaims 无 claims:仍恒 code 200(本地态必清)。
  639. func TestLogout_NoClaims(t *testing.T) {
  640. fx := newFixture(t)
  641. app := fx.newApp(nil)
  642. env := do(fx.t, app, http.MethodPost, "/auth/logout", `{}`, nil)
  643. if env.Code != 200 {
  644. t.Fatalf("logout code=%d(期望恒 200)", env.Code)
  645. }
  646. }
  647. // ---- 微信占位 ----
  648. // TestWechatQrcode_Placeholder 微信扫码占位:code 5001(暂未开放)。
  649. func TestWechatQrcode_Placeholder(t *testing.T) {
  650. fx := newFixture(t)
  651. app := fx.newApp(nil)
  652. env := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil)
  653. if env.Code != apperrors.CodeInternal {
  654. t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal)
  655. }
  656. }
  657. // TestWechatStatus_Placeholder 微信扫码状态占位:code 5001(暂未开放)。
  658. func TestWechatStatus_Placeholder(t *testing.T) {
  659. fx := newFixture(t)
  660. app := fx.newApp(nil)
  661. env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state=abc", "", nil)
  662. if env.Code != apperrors.CodeInternal {
  663. t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal)
  664. }
  665. }
  666. // md5Hex 计算 md5 的 hex 小写串(构造 md5_salt 测试夹具用,与 credential.legacyDigest 同语义)。
  667. func md5Hex(s string) string {
  668. d := md5.Sum([]byte(s))
  669. return hex.EncodeToString(d[:])
  670. }