// 验签中间件单测(T4-5 验收):Bearer 解析 + 验签 + 作用域注入 + 错误映射。 // // 用真 Issuer + miniredis(真 RS256 签发/验签 + 真黑名单),覆盖: // - 无 Authorization / 非 Bearer → 1001 // - 签名无效(错钥)/ 过期 / iss 不符 → 1001 // - 已吊销 jti → 1002(登录态失效) // - 合法 token → 放行 + c.Locals 含 *token.Claims + scope.Scope 注入正确(role/superadmin/作用域) package handler import ( "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/gofiber/fiber/v2" "yuansiwei/unified-backend/internal/identity/model" "yuansiwei/unified-backend/internal/identity/token" apperrors "yuansiwei/unified-backend/internal/infra/errors" "yuansiwei/unified-backend/internal/infra/scope" orgmodel "yuansiwei/unified-backend/internal/org/model" ) // authApp 起一个挂了 NewAuth 中间件 + 受保护 /auth/me 的 fiber 应用。 func authApp(t *testing.T, fx *fixture) *fiber.App { t.Helper() mw, err := NewAuth(AuthConfig{PublicKeys: fx.pubKeys, Revocation: fx.revoke, Issuer: "https://unified.test"}) if err != nil { t.Fatalf("NewAuth: %v", err) } app := fiber.New(fiber.Config{DisableStartupMessage: true}) grp := app.Group("/auth") grp.Use(mw) grp.Get("/me", fx.handler.Me) return app } // newIssuer 用指定密钥/issuer/TTL 造一个 Issuer(复用 fixture 的 refresh/revocation 存储)。 func newIssuer(t *testing.T, fx *fixture, privPEM, issuer string, accessTTL time.Duration) *token.Issuer { t.Helper() iss, err := token.NewIssuer(privPEM, issuer, accessTTL, 30*24*time.Hour, fx.refresh, fx.revoke) if err != nil { t.Fatalf("NewIssuer: %v", err) } return iss } // issueToken 用指定 issuer 签一个 access(只返回 access,专注验签链路)。 func issueToken(t *testing.T, iss *token.Issuer, c token.Claims) string { t.Helper() access, _, err := iss.Issue(context.Background(), c) if err != nil { t.Fatalf("Issue: %v", err) } return access } // doBearer 带 Bearer 头请求 /auth/me 并解出统一信封 code。 func doBearer(t *testing.T, app *fiber.App, authHeader string) int { t.Helper() req := httptest.NewRequest(http.MethodGet, "/auth/me", nil) if authHeader != "" { req.Header.Set("Authorization", authHeader) } resp, err := app.Test(req, -1) if err != nil { t.Fatalf("请求失败: %v", err) } var env envelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { t.Fatalf("解析响应失败: %v", err) } return env.Code } // TestAuth_NoAuthorization 无 Authorization 头:1001。 func TestAuth_NoAuthorization(t *testing.T) { fx := newFixture(t) app := authApp(t, fx) if code := doBearer(t, app, ""); code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 未登录)", code, apperrors.CodeUnauth) } } // TestAuth_NotBearer 非 Bearer scheme:1001。 func TestAuth_NotBearer(t *testing.T) { fx := newFixture(t) app := authApp(t, fx) if code := doBearer(t, app, "Basic abcdef"); code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 未登录)", code, apperrors.CodeUnauth) } } // TestAuth_BadSignature 签名无效(用另一对密钥签):1001。 func TestAuth_BadSignature(t *testing.T) { fx := newFixture(t) other := newFixture(t) // 不同密钥对 bad := issueToken(t, other.issuer, token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, }) app := authApp(t, fx) if code := doBearer(t, app, "Bearer "+bad); code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 签名无效)", code, apperrors.CodeUnauth) } } // TestAuth_Expired 过期 token:1001。 func TestAuth_Expired(t *testing.T) { fx := newFixture(t) shortIss := newIssuer(t, fx, fx.privPEM, "https://unified.test", 1*time.Second) exp := issueToken(t, shortIss, token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, }) time.Sleep(1100 * time.Millisecond) app := authApp(t, fx) if code := doBearer(t, app, "Bearer "+exp); code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d 过期)", code, apperrors.CodeUnauth) } } // TestAuth_WrongIssuer iss 不符:1001。 func TestAuth_WrongIssuer(t *testing.T) { fx := newFixture(t) otherIss := newIssuer(t, fx, fx.privPEM, "https://other.test", 30*time.Minute) // 同钥不同 iss bad := issueToken(t, otherIss, token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3, }) app := authApp(t, fx) // 验签 issuer = https://unified.test if code := doBearer(t, app, "Bearer "+bad); code != apperrors.CodeUnauth { t.Fatalf("code=%d(期望 %d iss 不符)", code, apperrors.CodeUnauth) } } // TestAuth_Revoked 已吊销 jti:1002(登录态失效)。 func TestAuth_Revoked(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" access := issueToken(t, fx.issuer, token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}) // 解析 jti 并拉黑(用 revocation=nil 的预验签取 jti,不影响黑名单) claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", access) if err != nil { t.Fatalf("预验签取 jti 失败: %v", err) } if err := fx.revoke.AddRevoked(context.Background(), claims.ID, time.Minute); err != nil { t.Fatalf("AddRevoked: %v", err) } app := authApp(t, fx) if code := doBearer(t, app, "Bearer "+access); code != apperrors.CodeTokenExpired { t.Fatalf("code=%d(期望 %d 登录态失效)", code, apperrors.CodeTokenExpired) } } // TestAuth_Success_Scope 合法 token:放行 + Me 成功(code 200)。 func TestAuth_Success_Scope(t *testing.T) { fx := newFixture(t) uid := "01GAQF0Z6C1V9Y3E5H7K2M8B4T" fx.users.byUID[uid] = &model.User{CanonicalUserID: uid, PersonType: model.PersonTypeStaff, Status: model.UserStatusActive} fx.memberships.add(uid, orgmodel.Membership{ MembershipID: 1, CanonicalUserID: uid, InstitutionID: 11, CampusID: 3, Role: "teacher", Status: "active", }) access := issueToken(t, fx.issuer, token.Claims{Sub: uid, Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}) app := authApp(t, fx) if code := doBearer(t, app, "Bearer "+access); code != 200 { t.Fatalf("code=%d(期望 200 放行)", code) } } // TestAuth_Success_SuperAdmin 超管 token:buildScope 置 SuperAdmin=true(跨机构不 scoping)。 func TestAuth_Success_SuperAdmin(t *testing.T) { fx := newFixture(t) access := issueToken(t, fx.issuer, token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"super_admin"}, InstitutionID: 0, CampusID: 0, }) claims, err := token.Verify(context.Background(), fx.pubKeys, nil, "https://unified.test", access) if err != nil { t.Fatalf("验签: %v", err) } sc := buildScope(claims) if !sc.SuperAdmin { t.Fatalf("SuperAdmin=%v(期望 true)", sc.SuperAdmin) } if sc.Role != "super_admin" { t.Fatalf("Role=%q(期望 super_admin)", sc.Role) } if sc.UserID != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" { t.Fatalf("UserID=%q(期望 ULID)", sc.UserID) } } // TestAuth_ScopeInjection 合法 token 后 scope 与 claims 均注入 c.Locals。 func TestAuth_ScopeInjection(t *testing.T) { fx := newFixture(t) mw, err := NewAuth(AuthConfig{PublicKeys: fx.pubKeys, Revocation: fx.revoke, Issuer: "https://unified.test"}) if err != nil { t.Fatalf("NewAuth: %v", err) } access := issueToken(t, fx.issuer, token.Claims{ Sub: "01GAQF0Z6C1V9Y3E5H7K2M8B4T", Roles: []string{"academic"}, InstitutionID: 11, CampusID: 3, }) var gotScope *scope.Scope var gotClaims *token.Claims app := fiber.New(fiber.Config{DisableStartupMessage: true}) app.Get("/x", mw, func(c *fiber.Ctx) error { gotScope = scope.FromFiber(c) if cl, ok := c.Locals(ClaimsCtxKey).(*token.Claims); ok { gotClaims = cl } return c.SendString("ok") }) req := httptest.NewRequest(http.MethodGet, "/x", nil) req.Header.Set("Authorization", "Bearer "+access) resp, err := app.Test(req, -1) if err != nil { t.Fatalf("请求失败: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("HTTP=%d(期望 200 放行)", resp.StatusCode) } if gotScope == nil { t.Fatal("scope 未注入") } if gotScope.InstitutionID != 11 || gotScope.CampusID != 3 || gotScope.Role != "academic" { t.Fatalf("scope=%+v(期望 inst=11 campus=3 role=academic)", gotScope) } if gotClaims == nil || gotClaims.Sub != "01GAQF0Z6C1V9Y3E5H7K2M8B4T" { t.Fatalf("claims 未正确注入: %+v", gotClaims) } }