// Handler 单测(T4-4 验收):/auth/* 端点端到端(mock 仓储 + 真 Issuer + miniredis,不连真实 DB/Redis)。 // // 覆盖: // - 入参校验(validator)→ code 2001 / 2002 // - 两步登录①② → code 200(含 loginTicket / accessToken) // - 防枚举统一文案 → code 1001(未知账号 / 密码错 / 禁用) // - 票据失效 / 重放 / 作用域越权 → code 1001 / 1003 // - Me(受保护,读中间件写入的 claims)/ Logout(恒 200 幂等)/ Refresh // - 微信占位 → code 5001(暂未开放) // // 断言纪律(响应契约 = code-based,HTTP 恒 200):只断 body.code(铁律 6), // 不依赖 HTTP 状态码(恒 200)。 package handler import ( "context" "crypto/md5" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/hex" "encoding/json" "encoding/pem" "net/http" "net/http/httptest" "strings" "testing" "time" miniredis "github.com/alicebob/miniredis/v2" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/identity/credential" "yuansiwei/unified-backend/internal/identity/model" "yuansiwei/unified-backend/internal/identity/repository" "yuansiwei/unified-backend/internal/identity/service" "yuansiwei/unified-backend/internal/identity/token" "yuansiwei/unified-backend/internal/infra/config" apperrors "yuansiwei/unified-backend/internal/infra/errors" appredis "yuansiwei/unified-backend/internal/infra/redis" orgmodel "yuansiwei/unified-backend/internal/org/model" ) // ---- 仓储 mock(实现 repository 接口,纯内存;与 service 单测同源逻辑)---- type mockUsers struct{ byUID map[string]*model.User } func (m *mockUsers) GetByCanonicalUserID(_ context.Context, uid string) (*model.User, error) { u, ok := m.byUID[uid] if !ok { return nil, apperrors.ErrNotFound } return u, nil } func (m *mockUsers) UpdatePasswordLazyUpgrade(_ context.Context, uid, newHash string) error { u, ok := m.byUID[uid] if !ok { return apperrors.ErrNotFound } u.PasswordHash = &newHash u.PasswordSalt = nil hv := model.HashVersionArgon2 u.HashVersion = &hv return nil } type mockIdentities struct{ byTypeIdent map[string]map[string]*model.Identity } func newMockIdentities() *mockIdentities { return &mockIdentities{byTypeIdent: map[string]map[string]*model.Identity{}} } func (m *mockIdentities) add(typ, identifier string, it *model.Identity) { if m.byTypeIdent[typ] == nil { m.byTypeIdent[typ] = map[string]*model.Identity{} } m.byTypeIdent[typ][identifier] = it } func (m *mockIdentities) GetByTypeIdentifier(_ context.Context, typ, identifier string) (*model.Identity, error) { it, ok := m.byTypeIdent[typ][identifier] if !ok { return nil, apperrors.ErrNotFound } return it, nil } type mockMemberships struct{ byUser map[string][]orgmodel.Membership } func (m *mockMemberships) add(uid string, ms ...orgmodel.Membership) { m.byUser[uid] = append(m.byUser[uid], ms...) } func (m *mockMemberships) ListByCanonicalUserID(_ context.Context, uid string) ([]orgmodel.Membership, error) { list := m.byUser[uid] if list == nil { return []orgmodel.Membership{}, nil } return list, nil } func (m *mockMemberships) GetByUserInstitutionCampus(_ context.Context, uid string, inst, campus int64) (*orgmodel.Membership, error) { var best *orgmodel.Membership for i := range m.byUser[uid] { mm := &m.byUser[uid][i] if mm.InstitutionID == inst && mm.CampusID == campus { if best == nil || mm.CreatedAt.After(best.CreatedAt) { best = mm } } } if best == nil { return nil, apperrors.ErrNotFound } return best, nil } // 编译期断言:mock 满足 repository 接口(签名漂移即编译失败)。 var ( _ repository.UserRepository = (*mockUsers)(nil) _ repository.IdentityRepository = (*mockIdentities)(nil) _ repository.MembershipRepository = (*mockMemberships)(nil) ) // ---- fixture ---- type fixture struct { t *testing.T users *mockUsers identities *mockIdentities memberships *mockMemberships handler *Handler pubKeys []string issuer *token.Issuer } // newFixture 起 miniredis + 共享 *Client + 真 Issuer(30min/30d) + AuthService + Handler。 func newFixture(t *testing.T) *fixture { t.Helper() mr, err := miniredis.Run() if err != nil { t.Fatalf("miniredis.Run: %v", err) } t.Cleanup(mr.Close) rc, err := appredis.NewClient(config.RedisCfg{Addr: mr.Addr()}) if err != nil { t.Fatalf("NewClient: %v", err) } t.Cleanup(func() { _ = rc.Close() }) privPEM, pubKeys := genRSAKey(t) refreshStore := appredis.NewRefreshStore(rc) revocation := appredis.NewRevocationStore(rc, "revoked") tickets := appredis.NewLoginTicketStore(rc) issuer, err := token.NewIssuer(privPEM, "https://unified.test", 30*time.Minute, 30*24*time.Hour, refreshStore, revocation) if err != nil { t.Fatalf("NewIssuer: %v", err) } users := &mockUsers{byUID: map[string]*model.User{}} ids := newMockIdentities() mergs := &mockMemberships{byUser: map[string][]orgmodel.Membership{}} svc, err := service.NewAuthService(service.Config{ Users: users, Identities: ids, Memberships: mergs, Issuer: issuer, Tickets: tickets, AccessTTL: 30 * time.Minute, PublicKeys: pubKeys, IssuerStr: "https://unified.test", Revocation: revocation, LoginTicketTTL: 5 * time.Minute, }) if err != nil { t.Fatalf("NewAuthService: %v", err) } h, err := New(Config{Svc: svc}) if err != nil { t.Fatalf("New Handler: %v", err) } return &fixture{t: t, users: users, identities: ids, memberships: mergs, handler: h, pubKeys: pubKeys, issuer: issuer} } // genRSAKey 生成 RS2048 私钥 + 公钥 PEM(PKCS1 私有 / PKIX 公共)。 func genRSAKey(t *testing.T) (string, []string) { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("生成 RSA 私钥: %v", err) } privPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) if err != nil { t.Fatalf("序列化公钥: %v", err) } pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})) return privPEM, []string{pubPEM} } // addUser 造一个 argon2 用户 + username 身份(测试夹具快捷构造)。 func (fx *fixture) addArgon2User(uid, username, password string) { fx.t.Helper() hash, err := credential.HashArgon2(password) if err != nil { fx.t.Fatalf("HashArgon2: %v", err) } fx.users.byUID[uid] = &model.User{ CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive, PasswordHash: &hash, HashVersion: ptr(model.HashVersionArgon2), } fx.identities.add(model.IdentityTypeUsername, username, &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: username}) } func ptr[T any](v T) *T { return &v } // ---- 响应解析 ---- // envelope 统一响应信封(断言用;Data 留原始 JSON 供按需再解析)。 type envelope struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` Success bool `json:"success"` } // do 发一个请求并解出统一信封(HTTP 恒 200,断 body.code)。 func do(t *testing.T, app *fiber.App, method, path, body string, headers map[string]string) *envelope { t.Helper() var req *http.Request if method == http.MethodGet { req = httptest.NewRequest(method, path, nil) } else { req = httptest.NewRequest(method, path, strings.NewReader(body)) } for k, v := range headers { req.Header.Set(k, v) } resp, err := app.Test(req, -1) if err != nil { t.Fatalf("%s %s 请求失败: %v", method, path, err) } if resp.StatusCode != http.StatusOK { t.Fatalf("%s %s HTTP 状态码=%d(铁律 6 要求恒 200)", method, path, resp.StatusCode) } var env envelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { t.Fatalf("%s %s 解析响应失败: %v", method, path, err) } return &env } // newApp 按端点清单构建 fiber 应用(公开路由 + 受保护路由;受保护路由前挂 claims 中间件)。 // withClaims 为 true 时向 /auth/me、/auth/logout 注入已验签 claims(模拟 T4-5 中间件写入)。 func (fx *fixture) newApp(claims *token.Claims) *fiber.App { fx.t.Helper() app := fiber.New(fiber.Config{DisableStartupMessage: true}) // 公开路由 app.Post("/auth/login", fx.handler.Login) app.Post("/auth/issue", fx.handler.Issue) app.Post("/auth/refresh", fx.handler.Refresh) app.Get("/auth/wechat/qrcode", fx.handler.WechatQrcode) app.Get("/auth/wechat/status", fx.handler.WechatStatus) // 受保护路由(模拟 T4-5 中间件:把 claims 写入 c.Locals(ClaimsCtxKey)) auth := app.Group("/auth") auth.Use(func(c *fiber.Ctx) error { if claims != nil { _ = c.Locals(ClaimsCtxKey, claims) } return c.Next() }) auth.Get("/me", fx.handler.Me) auth.Post("/logout", fx.handler.Logout) return app } // login 调 ① 并返回 loginTicket(供 ② 用)。失败即 Fatal。 func (fx *fixture) login(username, password string) string { fx.t.Helper() app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"`+username+`","password":"`+password+`"}`, nil) if env.Code != 200 { fx.t.Fatalf("login code=%d msg=%s(期望 200)", env.Code, env.Message) } var step1 struct { LoginTicket string `json:"loginTicket"` } if err := json.Unmarshal(env.Data, &step1); err != nil { fx.t.Fatalf("解析 loginTicket 失败: %v", err) } if step1.LoginTicket == "" { fx.t.Fatal("loginTicket 为空") } return step1.LoginTicket } // ---- Login(两步第一步)---- // TestLogin_Success argon2 命中:code 200,data 含 loginTicket + user + memberships。 func TestLogin_Success(t *testing.T) { fx := newFixture(t) fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password") fx.memberships.add("01GAQF0Z6C1V9Y3E5H7K2M8B4T", orgmodel.Membership{ MembershipID: 1, CanonicalUserID: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"testuser","password":"correct-password"}`, nil) if env.Code != 200 || !env.Success { t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message) } var step1 struct { LoginTicket string `json:"loginTicket"` User struct { ID string `json:"id"` PersonType string `json:"personType"` Status string `json:"status"` } `json:"user"` Memberships []struct { InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` Role string `json:"role"` } `json:"memberships"` } if err := json.Unmarshal(env.Data, &step1); err != nil { t.Fatalf("解析 data 失败: %v", err) } if step1.LoginTicket == "" { t.Fatal("loginTicket 缺失") } if len(step1.LoginTicket) != 32 { t.Fatalf("loginTicket 长度=%d(期望 32 hex)", len(step1.LoginTicket)) } if step1.User.ID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" { t.Fatalf("user.id=%q(期望 ULID)", step1.User.ID) } if len(step1.Memberships) != 1 || step1.Memberships[0].Role != "teacher" { t.Fatalf("memberships=%+v(期望 1 条 teacher)", step1.Memberships) } } // TestLogin_WrongPassword 密码错:code 1001(统一文案,防枚举)。 func TestLogin_WrongPassword(t *testing.T) { fx := newFixture(t) fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password") app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"testuser","password":"wrong-password"}`, nil) if env.Code != apperrors.CodeUnauth { t.Fatalf("code=%d msg=%s(期望 %d 统一文案)", env.Code, env.Message, apperrors.CodeUnauth) } if env.Success { t.Fatal("success 应为 false") } } // TestLogin_UnknownUsername 未知账号:code 1001(与密码错同码,防枚举)。 func TestLogin_UnknownUsername(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"ghost","password":"whatever"}`, nil) if env.Code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 统一文案,防枚举)", env.Code, apperrors.CodeUnauth) } } // TestLogin_DisabledUser 禁用账号:code 1001(统一文案,不泄漏"存在但禁用")。 func TestLogin_DisabledUser(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.users.byUID[uid].Status = model.UserStatusDisabled app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"testuser","password":"correct-password"}`, nil) if env.Code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 统一文案)", env.Code, apperrors.CodeUnauth) } } // TestLogin_Md5Salt_LazyUpgrade 命中 md5_salt:升级 argon2 后成功(code 200)。 func TestLogin_Md5Salt_LazyUpgrade(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" salt := "salty" // md5(password+salt) hex 小写(教务旧算法) d := md5Hex("correct-password" + salt) fx.users.byUID[uid] = &model.User{ CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive, PasswordHash: &d, PasswordSalt: ptr(salt), HashVersion: ptr(model.HashVersionMd5Salt), } fx.identities.add(model.IdentityTypeUsername, "testuser", &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeUsername, Identifier: "testuser"}) fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"testuser","password":"correct-password"}`, nil) if env.Code != 200 { t.Fatalf("code=%d msg=%s(期望 200:惰性升级后放行)", env.Code, env.Message) } // 升级写回后 hash_version 应为 argon2 if fx.users.byUID[uid].HashVersion == nil || *fx.users.byUID[uid].HashVersion != model.HashVersionArgon2 { t.Fatalf("hash_version=%v(期望升级后为 argon2)", fx.users.byUID[uid].HashVersion) } } // TestLogin_BadParam 空账号:code 2001(validator 校验失败)。 func TestLogin_BadParam(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{"username":"","password":"whatever"}`, nil) if env.Code != apperrors.CodeBadParam { t.Fatalf("code=%d(期望 %d 参数缺失)", env.Code, apperrors.CodeBadParam) } } // TestLogin_BadFormat 非法 JSON:code 2002(参数格式错误)。 func TestLogin_BadFormat(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/login", `{bad json`, nil) if env.Code != apperrors.CodeBadFormat { t.Fatalf("code=%d(期望 %d 格式错误)", env.Code, apperrors.CodeBadFormat) } } // ---- Issue(两步第二步)---- // TestIssue_Success 合法票据 + 归属作用域:code 200,data 含 accessToken + institutionId + roles。 func TestIssue_Success(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) ticket := fx.login("testuser", "correct-password") app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil) if env.Code != 200 || !env.Success { t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message) } var pair struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` Roles []string `json:"roles"` } if err := json.Unmarshal(env.Data, &pair); err != nil { t.Fatalf("解析 data 失败: %v", err) } if pair.AccessToken == "" || pair.RefreshToken == "" { t.Fatalf("accessToken/refreshToken 缺失: %+v", pair) } if pair.InstitutionID != 11 || pair.CampusID != 3 { t.Fatalf("作用域=%d/%d(期望 11/3)", pair.InstitutionID, pair.CampusID) } if len(pair.Roles) != 1 || pair.Roles[0] != "teacher" { t.Fatalf("roles=%v(期望 [teacher])", pair.Roles) } // access 应可被本公钥验签 claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", pair.AccessToken) if err != nil { t.Fatalf("验签 access 失败: %v", err) } if claims.Sub != uid { t.Fatalf("claims.Sub=%q(期望 %q)", claims.Sub, uid) } } // TestIssue_InvalidTicket 非法/不存在票据:code 1001。 func TestIssue_InvalidTicket(t *testing.T) { fx := newFixture(t) fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password") app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"00000000000000000000000000000000","institutionId":11,"campusId":3}`, nil) if env.Code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 票据失效)", env.Code, apperrors.CodeUnauth) } } // TestIssue_ReplayTicket 同一票据二次消费:code 1001(GETDEL 原子,防重放)。 func TestIssue_ReplayTicket(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) ticket := fx.login("testuser", "correct-password") app := fx.newApp(nil) body := `{"loginTicket":"` + ticket + `","institutionId":11,"campusId":3}` if env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil); env.Code != 200 { t.Fatalf("首次消费 code=%d(期望 200)", env.Code) } // 重放:票据已被 GETDEL 消费,应失效 env := do(fx.t, app, http.MethodPost, "/auth/issue", body, nil) if env.Code != apperrors.CodeUnauth { t.Fatalf("重放 code=%d(期望 %d 票据已消费)", env.Code, apperrors.CodeUnauth) } } // TestIssue_ScopeNotBelong 作用域不属于该账号:code 1003(越权)。 func TestIssue_ScopeNotBelong(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) ticket := fx.login("testuser", "correct-password") app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"`+ticket+`","institutionId":99,"campusId":99}`, nil) if env.Code != apperrors.CodeForbidden { t.Fatalf("code=%d(期望 %d 作用域越权)", env.Code, apperrors.CodeForbidden) } } // TestIssue_ScopeInactive 归属已失效(inactive):code 1003。 func TestIssue_ScopeInactive(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "inactive", }) ticket := fx.login("testuser", "correct-password") app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil) if env.Code != apperrors.CodeForbidden { t.Fatalf("code=%d(期望 %d 归属失效)", env.Code, apperrors.CodeForbidden) } } // TestIssue_BadParam 票据长度非 32:code 2001。 func TestIssue_BadParam(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"short","institutionId":11,"campusId":3}`, nil) if env.Code != apperrors.CodeBadParam { t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam) } } // ---- Me(受保护)---- // TestMe_Success 已验签 claims 注入:code 200,data 含 user + roles + 作用域 + memberships。 func TestMe_Success(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3} app := fx.newApp(claims) env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil) if env.Code != 200 || !env.Success { t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message) } var me struct { ID string `json:"id"` Roles []string `json:"roles"` InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` Memberships []struct { Role string `json:"role"` } `json:"memberships"` } if err := json.Unmarshal(env.Data, &me); err != nil { t.Fatalf("解析 data 失败: %v", err) } if me.ID != uid { t.Fatalf("id=%q(期望 %q)", me.ID, uid) } if len(me.Memberships) != 1 || me.Memberships[0].Role != "teacher" { t.Fatalf("memberships=%+v(期望 1 条 teacher)", me.Memberships) } } // TestMe_Unauth 无 claims(中间件未注入):code 1001(未登录)。 func TestMe_Unauth(t *testing.T) { fx := newFixture(t) fx.addArgon2User("01GAQF0Z6C1V9Y3E5H7K2M8B4T", "testuser", "correct-password") app := fx.newApp(nil) // 无 claims env := do(fx.t, app, http.MethodGet, "/auth/me", "", nil) if env.Code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 未登录)", env.Code, apperrors.CodeUnauth) } } // ---- Refresh ---- // TestRefresh_Success 合法 refresh:code 200,返回新 token 对。 func TestRefresh_Success(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) ticket := fx.login("testuser", "correct-password") app := fx.newApp(nil) issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil) if issueEnv.Code != 200 { t.Fatalf("issue code=%d(期望 200)", issueEnv.Code) } var pair struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` } _ = json.Unmarshal(issueEnv.Data, &pair) // 用旧 refresh 轮换 env := do(fx.t, app, http.MethodPost, "/auth/refresh", `{"refreshToken":"`+pair.RefreshToken+`"}`, nil) if env.Code != 200 { t.Fatalf("refresh code=%d msg=%s(期望 200)", env.Code, env.Message) } } // TestRefresh_Invalid 合法格式(64 hex)但不在白名单:code 1002(登录态失效,前端跳登录)。 func TestRefresh_Invalid(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/refresh", `{"refreshToken":"`+strings.Repeat("0", 64)+`"}`, nil) if env.Code != apperrors.CodeTokenExpired { t.Fatalf("code=%d(期望 %d 登录态失效)", env.Code, apperrors.CodeTokenExpired) } } // TestRefresh_BadParam refresh 长度非 48:code 2001。 func TestRefresh_BadParam(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/refresh", `{"refreshToken":"too-short"}`, nil) if env.Code != apperrors.CodeBadParam { t.Fatalf("code=%d(期望 %d 参数校验)", env.Code, apperrors.CodeBadParam) } } // ---- Logout(恒 200 幂等)---- // TestLogout_Success 有 claims + refresh:恒 code 200。 func TestLogout_Success(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addArgon2User(uid, "testuser", "correct-password") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) ticket := fx.login("testuser", "correct-password") app := fx.newApp(nil) issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"`+ticket+`","institutionId":11,"campusId":3}`, nil) var pair struct { RefreshToken string `json:"refreshToken"` } _ = json.Unmarshal(issueEnv.Data, &pair) // 带 claims 登出(模拟已验签中间件写入) claims := &token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, ID: "some-jti"} appAuth := fx.newApp(claims) env := do(fx.t, appAuth, http.MethodPost, "/auth/logout", `{"refreshToken":"`+pair.RefreshToken+`"}`, nil) if env.Code != 200 || !env.Success { t.Fatalf("logout code=%d(期望恒 200 幂等)", env.Code) } } // TestLogout_NoClaims 无 claims:仍恒 code 200(本地态必清)。 func TestLogout_NoClaims(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodPost, "/auth/logout", `{}`, nil) if env.Code != 200 { t.Fatalf("logout code=%d(期望恒 200)", env.Code) } } // ---- 微信占位 ---- // TestWechatQrcode_Placeholder 微信扫码占位:code 5001(暂未开放)。 func TestWechatQrcode_Placeholder(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil) if env.Code != apperrors.CodeInternal { t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal) } } // TestWechatStatus_Placeholder 微信扫码状态占位:code 5001(暂未开放)。 func TestWechatStatus_Placeholder(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state=abc", "", nil) if env.Code != apperrors.CodeInternal { t.Fatalf("code=%d(期望 %d 暂未开放)", env.Code, apperrors.CodeInternal) } } // md5Hex 计算 md5 的 hex 小写串(构造 md5_salt 测试夹具用,与 credential.legacyDigest 同语义)。 func md5Hex(s string) string { d := md5.Sum([]byte(s)) return hex.EncodeToString(d[:]) }