// 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 ---- // fakeWechat 微信客户端打桩:code→openid 映射(默认返回 "openid-fixed";可注入失败)。 type fakeWechat struct { byCode map[string]string fail bool called int } func (f *fakeWechat) ExchangeCode(_ context.Context, _, _, code, _ string) (string, error) { f.called++ if f.fail { return "", &wechatErr{} } if oid, ok := f.byCode[code]; ok { return oid, nil } return "", &wechatErr{} } // wechatErr 实现 wechat 包的"换 openid 失败"错误形态(IsTokenError 兼容:此处仅需非 nil error)。 type wechatErr struct{} func (*wechatErr) Error() string { return "wechat: 换 openid 失败(测试打桩)" } type fixture struct { t *testing.T users *mockUsers identities *mockIdentities memberships *mockMemberships wechat *fakeWechat wxSvc *service.WechatService handler *Handler pubKeys []string privPEM string issuer *token.Issuer revoke *appredis.RevocationStore refresh *appredis.RefreshStore } // 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) wxStates := appredis.NewWechatQrcodeStore(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) } fw := &fakeWechat{byCode: map[string]string{}} wxSvc, err := service.NewWechatService(service.WechatConfig{ Client: fw, States: wxStates, Tickets: tickets, Identities: ids, Memberships: mergs, Campuses: []service.WechatCampus{{CampusID: 3, AppID: "appid-test", Secret: "secret-test"}}, CallbackURL: "https://unified.test/auth/wechat/callback", }) if err != nil { t.Fatalf("NewWechatService: %v", err) } h, err := New(Config{Svc: svc, Wechat: wxSvc}) if err != nil { t.Fatalf("New Handler: %v", err) } return &fixture{ t: t, users: users, identities: ids, memberships: mergs, wechat: fw, wxSvc: wxSvc, handler: h, pubKeys: pubKeys, privPEM: privPEM, issuer: issuer, revoke: revocation, refresh: refreshStore, } } // 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} } // addWechatUser 造一个用户 + wechat 身份(type=wechat, identifier=openid;扫码登录绑定用)。 func (fx *fixture) addWechatUser(uid, openid string) { fx.t.Helper() if fx.users.byUID[uid] == nil { fx.users.byUID[uid] = &model.User{CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive} } fx.identities.add(model.IdentityTypeWechat, openid, &model.Identity{CanonicalUserID: uid, Type: model.IdentityTypeWechat, Identifier: openid, Verified: true}) } // 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/authorize", fx.handler.WechatAuthorize) app.Get("/auth/wechat/status", fx.handler.WechatStatus) app.Get("/auth/wechat/callback", fx.handler.WechatCallback) // 受保护路由(模拟 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_Success 生成二维码:code 200,data 含 state(32hex) + qrUrl(含 state) + campusId + expiresIn。 func TestWechatQrcode_Success(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil) if env.Code != 200 || !env.Success { t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message) } var qr struct { State string `json:"state"` QRURL string `json:"qrUrl"` CampusID int64 `json:"campusId"` ExpiresIn int64 `json:"expiresIn"` } if err := json.Unmarshal(env.Data, &qr); err != nil { t.Fatalf("解析 data 失败: %v", err) } if len(qr.State) != 32 { t.Fatalf("state 长度=%d(期望 32 hex)", len(qr.State)) } if !strings.Contains(qr.QRURL, "open.weixin.qq.com/connect/qrconnect") { t.Fatalf("qrUrl=%q(期望微信网页授权 URL)", qr.QRURL) } if !strings.Contains(qr.QRURL, "state="+qr.State) { t.Fatalf("qrUrl=%q(期望携带 state=%q)", qr.QRURL, qr.State) } if qr.CampusID != 3 { t.Fatalf("campusId=%d(期望 3,配置首个 campus)", qr.CampusID) } if qr.ExpiresIn <= 0 { t.Fatalf("expiresIn=%d(期望 >0 秒)", qr.ExpiresIn) } } // TestWechatStatus_Pending 新 state 轮询:status=pending(继续轮询)。 func TestWechatStatus_Pending(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) qr := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil) var q struct { State string `json:"state"` } _ = json.Unmarshal(qr.Data, &q) env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+q.State, "", nil) if env.Code != 200 { t.Fatalf("code=%d msg=%s(期望 200)", env.Code, env.Message) } var st struct { Status string `json:"status"` } if err := json.Unmarshal(env.Data, &st); err != nil { t.Fatalf("解析 data 失败: %v", err) } if st.Status != "pending" { t.Fatalf("status=%q(期望 pending)", st.Status) } } // TestWechatStatus_Expired 未知 state 轮询:expired=true(前端停轮询,提示刷新)。 func TestWechatStatus_Expired(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state=doesnotexist00000000000000000000", "", nil) if env.Code != 200 { t.Fatalf("code=%d msg=%s(期望 200,expired 语义非故障)", env.Code, env.Message) } var st struct { Expired bool `json:"expired"` } if err := json.Unmarshal(env.Data, &st); err != nil { t.Fatalf("解析 data 失败: %v", err) } if !st.Expired { t.Fatal("未知 state 应 expired=true") } } // TestWechatCallback_Success 扫码成功(已绑定):code 换 openid → 发 loginTicket → status=success。 // 校验:callback 返回 success + loginTicket + userId;state 内票据可被 ② issue 消费(复用两步②)。 func TestWechatCallback_Success(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addWechatUser(uid, "openid-bound") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) fx.wechat.byCode["code-ok"] = "openid-bound" app := fx.newApp(nil) // ① 生成二维码 qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil) var qr struct { State string `json:"state"` } _ = json.Unmarshal(qrEnv.Data, &qr) // ② 微信回调:code=code-ok, state= cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil) if cbEnv.Code != 200 { t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message) } var cb struct { Status string `json:"status"` LoginTicket string `json:"loginTicket"` UserID string `json:"userId"` Memberships []struct { InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` Role string `json:"role"` } `json:"memberships"` } if err := json.Unmarshal(cbEnv.Data, &cb); err != nil { t.Fatalf("解析 callback data 失败: %v", err) } if cb.Status != "success" { t.Fatalf("status=%q(期望 success)", cb.Status) } if cb.UserID != uid { t.Fatalf("userId=%q(期望 %q)", cb.UserID, uid) } if cb.LoginTicket == "" || len(cb.LoginTicket) != 32 { t.Fatalf("loginTicket=%q(期望 32 hex)", cb.LoginTicket) } // success 应回显可选作用域列表(与账密登录 LoginStep1 同源;前端据此走同一套 ② 分流) if len(cb.Memberships) != 1 || cb.Memberships[0].Role != "teacher" || cb.Memberships[0].InstitutionID != 11 { t.Fatalf("memberships=%+v(期望 1 条 inst=11 role=teacher)", cb.Memberships) } // ③ 轮询确认 state=success 且携带同一 loginTicket stEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+qr.State, "", nil) var st struct { Status string `json:"status"` LoginTicket string `json:"loginTicket"` } _ = json.Unmarshal(stEnv.Data, &st) if st.Status != "success" || st.LoginTicket != cb.LoginTicket { t.Fatalf("轮询 status=%q ticket=%q(期望 success + 同票据)", st.Status, st.LoginTicket) } // ④ 复用两步②:携 loginTicket 签 token(证明微信登录与账密登录走同一套 ②) issueEnv := do(fx.t, app, http.MethodPost, "/auth/issue", `{"loginTicket":"`+cb.LoginTicket+`","institutionId":11,"campusId":3}`, nil) if issueEnv.Code != 200 { t.Fatalf("issue code=%d msg=%s(期望 200:微信票据应可走同一套 ②)", issueEnv.Code, issueEnv.Message) } } // TestWechatCallback_Unbound 扫码成功但 openid 未绑定:status=unbound(前端提示"未绑定账号")。 func TestWechatCallback_Unbound(t *testing.T) { fx := newFixture(t) fx.wechat.byCode["code-x"] = "openid-not-bound" // 不造该 openid 的身份记录 app := fx.newApp(nil) qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil) var qr struct { State string `json:"state"` } _ = json.Unmarshal(qrEnv.Data, &qr) cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-x&state="+qr.State, "", nil) if cbEnv.Code != 200 { t.Fatalf("callback code=%d msg=%s(期望 200,unbound 语义)", cbEnv.Code, cbEnv.Message) } var cb struct { Status string `json:"status"` } _ = json.Unmarshal(cbEnv.Data, &cb) if cb.Status != "unbound" { t.Fatalf("status=%q(期望 unbound)", cb.Status) } } // TestWechatCallback_ThirdPartyFail 微信换 openid 失败:status=fail + code 4003(第三方超时/失败)。 func TestWechatCallback_ThirdPartyFail(t *testing.T) { fx := newFixture(t) fx.wechat.fail = true // 打桩:换 openid 失败 app := fx.newApp(nil) qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil) var qr struct { State string `json:"state"` } _ = json.Unmarshal(qrEnv.Data, &qr) cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=bad&state="+qr.State, "", nil) if cbEnv.Code != apperrors.CodeThirdTimeout { t.Fatalf("callback code=%d(期望 %d 第三方失败)", cbEnv.Code, apperrors.CodeThirdTimeout) } // state 应被置 fail(前端后续轮询读到 fail) stEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/status?state="+qr.State, "", nil) var st struct { Status string `json:"status"` } _ = json.Unmarshal(stEnv.Data, &st) if st.Status != "fail" { t.Fatalf("state 应置 fail,实际 %q", st.Status) } } // TestWechatCallback_Replay 已终态 state 二次回调:code 1001(防重放)。 func TestWechatCallback_Replay(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addWechatUser(uid, "openid-bound") fx.wechat.byCode["code-ok"] = "openid-bound" app := fx.newApp(nil) qrEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/qrcode", "", nil) var qr struct { State string `json:"state"` } _ = json.Unmarshal(qrEnv.Data, &qr) // 首次回调 → success(终态) if env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil); env.Code != 200 { t.Fatalf("首次回调 code=%d(期望 200)", env.Code) } // 二次回同样 state → 1001(已终态,防重放) env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-ok&state="+qr.State, "", nil) if env.Code != apperrors.CodeUnauth { t.Fatalf("重放 callback code=%d(期望 %d 已终态)", env.Code, apperrors.CodeUnauth) } } // TestWechatCallback_MissingParam 缺 code 或 state:code 2001(参数校验)。 func TestWechatCallback_MissingParam(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=only-code", "", nil) if env.Code != apperrors.CodeBadParam { t.Fatalf("code=%d(期望 %d 缺 state 参数)", env.Code, apperrors.CodeBadParam) } } // TestWechat_NotConfigured wechat 服务未注入:qrcode/status/callback 均 code 4004(未配置)。 func TestWechat_NotConfigured(t *testing.T) { fx := newFixture(t) // 构造一个 wechat 为 nil 的 handler(复用 fx.svc 之外的 AuthService,此处直接重造 Handler) svc := fx.handler.svc // 复用现有 svc h, err := New(Config{Svc: svc}) // Wechat 缺省 nil if err != nil { t.Fatalf("New Handler: %v", err) } app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Get("/auth/wechat/qrcode", h.WechatQrcode) app.Get("/auth/wechat/authorize", h.WechatAuthorize) app.Get("/auth/wechat/status", h.WechatStatus) app.Get("/auth/wechat/callback", h.WechatCallback) for _, path := range []string{ "/auth/wechat/qrcode", "/auth/wechat/authorize", "/auth/wechat/status?state=x", "/auth/wechat/callback?code=x&state=x", } { env := do(fx.t, app, http.MethodGet, path, "", nil) if env.Code != apperrors.CodeThirdNotConfigured { t.Fatalf("%s code=%d(期望 %d 未配置)", path, env.Code, apperrors.CodeThirdNotConfigured) } } } // ---- 移动端 App 内微信授权(webview,snsapi_userinfo)---- // TestWechatAuthorize_Success 生成授权 URL:code 200,data 含 state(32hex) + authorizeUrl(oauth2/authorize + snsapi_userinfo) // + campusId + expiresIn。authorizeUrl 与 qrUrl 的差异:endpoint=oauth2/authorize(非 qrconnect)、 // scope=snsapi_userinfo(非 snsapi_login)、**无** #wechat_redirect 尾段。 func TestWechatAuthorize_Success(t *testing.T) { fx := newFixture(t) app := fx.newApp(nil) env := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil) if env.Code != 200 || !env.Success { t.Fatalf("code=%d msg=%s(期望 200 成功)", env.Code, env.Message) } var ar struct { State string `json:"state"` AuthorizeURL string `json:"authorizeUrl"` CampusID int64 `json:"campusId"` ExpiresIn int64 `json:"expiresIn"` } if err := json.Unmarshal(env.Data, &ar); err != nil { t.Fatalf("解析 data 失败: %v", err) } if len(ar.State) != 32 { t.Fatalf("state 长度=%d(期望 32 hex)", len(ar.State)) } // endpoint 应为 oauth2/authorize(非 qrconnect) if !strings.Contains(ar.AuthorizeURL, "open.weixin.qq.com/connect/oauth2/authorize") { t.Fatalf("authorizeUrl=%q(期望 connect/oauth2/authorize)", ar.AuthorizeURL) } // scope 应为 snsapi_userinfo(非 snsapi_login) if !strings.Contains(ar.AuthorizeURL, "scope=snsapi_userinfo") { t.Fatalf("authorizeUrl=%q(期望 scope=snsapi_userinfo)", ar.AuthorizeURL) } // 无 #wechat_redirect 尾段(webview 场景不保留) if strings.Contains(ar.AuthorizeURL, "#wechat_redirect") { t.Fatalf("authorizeUrl=%q(webview 场景不应含 #wechat_redirect)", ar.AuthorizeURL) } if !strings.Contains(ar.AuthorizeURL, "state="+ar.State) { t.Fatalf("authorizeUrl=%q(期望携带 state=%q)", ar.AuthorizeURL, ar.State) } if ar.CampusID != 3 { t.Fatalf("campusId=%d(期望 3,配置首个 campus)", ar.CampusID) } if ar.ExpiresIn <= 0 { t.Fatalf("expiresIn=%d(期望 >0 秒)", ar.ExpiresIn) } } // TestWechatAuthorize_ReuseCallback 授权 URL 的 state 可被同一 /auth/wechat/callback 消费 // (证明 PC 扫码与移动 webview 共用同一 callback 换 openid,无需独立端点)。 func TestWechatAuthorize_ReuseCallback(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.addWechatUser(uid, "openid-mobile") fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) fx.wechat.byCode["code-mobile"] = "openid-mobile" app := fx.newApp(nil) // ① 生成移动 webview 授权 URL authEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/authorize", "", nil) var ar struct { State string `json:"state"` } _ = json.Unmarshal(authEnv.Data, &ar) // ② 模拟微信重定向回 callback(同 PC 扫码的同一端点) cbEnv := do(fx.t, app, http.MethodGet, "/auth/wechat/callback?code=code-mobile&state="+ar.State, "", nil) if cbEnv.Code != 200 { t.Fatalf("callback code=%d msg=%s(期望 200 成功)", cbEnv.Code, cbEnv.Message) } var cb struct { Status string `json:"status"` LoginTicket string `json:"loginTicket"` } _ = json.Unmarshal(cbEnv.Data, &cb) if cb.Status != "success" || cb.LoginTicket == "" { t.Fatalf("status=%q ticket=%q(期望 success + 票据)", cb.Status, cb.LoginTicket) } } // md5Hex 计算 md5 的 hex 小写串(构造 md5_salt 测试夹具用,与 credential.legacyDigest 同语义)。 func md5Hex(s string) string { d := md5.Sum([]byte(s)) return hex.EncodeToString(d[:]) }