|
|
@@ -0,0 +1,732 @@
|
|
|
+// 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[:])
|
|
|
+}
|