// token 包单测(T4-6 验收):Issue / Verify / Rotate / Revoke / DeleteRefreshToken + 错误路径。 // // 用 **in-memory** 的 RefreshStore / RevocationStore(纯 map,不连 Redis)+ 真 RS256 密钥, // 专注 token 包的签发/验签/轮换/吊销逻辑(Redis 存储层的语义已由各 *Store 的 miniredis 测试覆盖, // 见 infra/redis/store_test.go)。 // // 覆盖: // - NewIssuer:私钥解析成功 / PKCS8 / 非法 PEM 报错 / 非 RSA 报错 // - Issue:Sub 必填校验 / 签出可验的 access + refresh(rjti 写白名单) // - Verify:合法 token 通过 / 签名错 / 过期 / iss 不符 / 黑名单命中→ErrRevoked // - Rotate:旧 refresh 换新对 / 旧串立即失效(再 Rotate→ErrRefreshInvalid)/ 非法格式 // - DeleteRefreshToken:合法 64hex 删除 / 非法格式报错 / 幂等(不存在不报错) package token import ( "context" stderrors "errors" "fmt" "testing" "time" ) // ---- in-memory 存储 ---- // memRefresh 纯 map 的 refresh 白名单。 type memRefresh struct{ m map[string][]byte } func newMemRefresh() *memRefresh { return &memRefresh{m: map[string][]byte{}} } func (s *memRefresh) SetRefresh(_ context.Context, jti string, payload []byte, _ time.Duration) error { s.m[jti] = payload return nil } func (s *memRefresh) GetRefresh(_ context.Context, jti string) ([]byte, error) { b, ok := s.m[jti] if !ok { return nil, nil } return b, nil } func (s *memRefresh) DeleteRefresh(_ context.Context, jti string) error { delete(s.m, jti) // 幂等:不存在不报错 return nil } // memRevocation 纯 map 的吊销黑名单(可注入故障模拟 Redis 不可用)。 type memRevocation struct { m map[string]struct{} failOn bool // 置 true 时 IsRevoked 返 error(模拟存储故障,验 fail-closed) } func newMemRevocation() *memRevocation { return &memRevocation{m: map[string]struct{}{}} } func (s *memRevocation) AddRevoked(_ context.Context, jti string, _ time.Duration) error { s.m[jti] = struct{}{} return nil } func (s *memRevocation) IsRevoked(_ context.Context, jti string) (bool, error) { if s.failOn { return false, stderrors.New("mem: 模拟存储故障") } _, ok := s.m[jti] return ok, nil } var ( _ RefreshStore = (*memRefresh)(nil) _ RevocationStore = (*memRevocation)(nil) ) // testKey 造一对 RS2048 密钥(PKCS1 私钥 + PKIX 公钥)+ 就绪 Issuer。 func testKey(t *testing.T, accessTTL time.Duration) (privPEM string, pubPEM string, iss *Issuer, ref *memRefresh, rev *memRevocation) { t.Helper() privPEM, pubPEM = genKeyPair(t) ref = newMemRefresh() rev = newMemRevocation() var err error iss, err = NewIssuer(privPEM, "https://unified.test", accessTTL, 30*24*time.Hour, ref, rev) if err != nil { t.Fatalf("NewIssuer: %v", err) } return privPEM, pubPEM, iss, ref, rev } // ---- NewIssuer ---- // TestNewIssuer_InvalidPEM 非法 PEM:报错(fail-fast 不启动)。 func TestNewIssuer_InvalidPEM(t *testing.T) { _, err := NewIssuer("not-a-pem", "https://x", 30*time.Minute, 30*24*time.Hour, newMemRefresh(), newMemRevocation()) if err == nil { t.Fatal("期望非法 PEM 报错,实际 nil") } } // TestNewIssuer_PKCS8 PKCS8 私钥也能解析(与 PKCS1 并列支持)。 func TestNewIssuer_PKCS8(t *testing.T) { priv8, pubPEM := genKeyPairPKCS8(t) iss, err := NewIssuer(priv8, "https://x", 30*time.Minute, 30*24*time.Hour, newMemRefresh(), newMemRevocation()) if err != nil { t.Fatalf("PKCS8 解析失败: %v", err) } access, _, err := iss.Issue(context.Background(), Claims{Sub: "uid-1"}) if err != nil { t.Fatalf("Issue: %v", err) } if _, err := Verify(context.Background(), []string{pubPEM}, newMemRevocation(), "https://x", access); err != nil { t.Fatalf("PKCS8 签的 token 应可验: %v", err) } } // ---- Issue ---- // TestIssue_EmptySub Sub 为空:报错(调用方 bug,单测暴露)。 func TestIssue_EmptySub(t *testing.T) { _, _, iss, _, _ := testKey(t, 30*time.Minute) _, _, err := iss.Issue(context.Background(), Claims{}) if err == nil { t.Fatal("期望空 Sub 报错,实际 nil") } } // TestIssue_RoundTrip 签发后 access 可被验签、refresh 写入白名单。 func TestIssue_RoundTrip(t *testing.T) { _, pubPEM, iss, ref, rev := testKey(t, 30*time.Minute) ctx := context.Background() access, refresh, err := iss.Issue(ctx, Claims{Sub: "uid-1", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}) if err != nil { t.Fatalf("Issue: %v", err) } if access == "" || refresh == "" { t.Fatalf("token 对缺失: %q / %q", access, refresh) } claims, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access) if err != nil { t.Fatalf("Verify: %v", err) } if claims.Sub != "uid-1" || claims.InstitutionID != 11 || claims.CampusID != 3 { t.Fatalf("claims=%+v(期望 uid-1/11/3)", claims) } if len(claims.Roles) != 1 || claims.Roles[0] != "teacher" { t.Fatalf("roles=%v(期望 [teacher])", claims.Roles) } // refresh 应已写白名单(jti = 前 8 字节 hex) rjti := refresh[:16] // 前 8 字节 → 16 hex 字符 if _, ok := ref.m[rjti]; !ok { t.Fatalf("refresh 白名单未写入 jti=%s", rjti) } } // ---- Verify ---- // TestVerify_Revoked 黑名单命中:ErrRevoked。 func TestVerify_Revoked(t *testing.T) { _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute) access, _, err := iss.Issue(context.Background(), Claims{Sub: "uid-1"}) if err != nil { t.Fatalf("Issue: %v", err) } claims, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access) if err != nil { t.Fatalf("预验签取 jti: %v", err) } _ = rev.AddRevoked(context.Background(), claims.ID, time.Minute) if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrRevoked) { t.Fatalf("期望 ErrRevoked,实际 %v", err) } } // TestVerify_StorageFailClosed 黑名单存储故障:返回 error(fail-closed,非 ErrRevoked/ErrInvalidToken)。 func TestVerify_StorageFailClosed(t *testing.T) { _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute) access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"}) rev.failOn = true // 模拟 Redis 故障 _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access) if err == nil || stderrors.Is(err, ErrInvalidToken) || stderrors.Is(err, ErrRevoked) { t.Fatalf("期望存储故障 error(fail-closed),实际 %v", err) } } // TestVerify_WrongIssuer iss 不符:ErrInvalidToken。 func TestVerify_WrongIssuer(t *testing.T) { _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute) access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"}) if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://other.test", access); !stderrors.Is(err, ErrInvalidToken) { t.Fatalf("期望 ErrInvalidToken(iss 不符),实际 %v", err) } } // TestVerify_BadSignature 签名无效:ErrInvalidToken(用不同公钥验)。 func TestVerify_BadSignature(t *testing.T) { _, _, iss, _, rev := testKey(t, 30*time.Minute) access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"}) _, pubOther := genKeyPair(t) // 另一对公钥 if _, err := Verify(context.Background(), []string{pubOther}, rev, "https://unified.test", access); !stderrors.Is(err, ErrInvalidToken) { t.Fatalf("期望 ErrInvalidToken(签名错),实际 %v", err) } } // TestVerify_Garbage 非 JWT 串:ErrInvalidToken。 func TestVerify_Garbage(t *testing.T) { _, pubPEM, _, _, rev := testKey(t, 30*time.Minute) if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", "not-a-jwt"); !stderrors.Is(err, ErrInvalidToken) { t.Fatalf("期望 ErrInvalidToken(格式损坏),实际 %v", err) } } // TestVerify_Expired 过期 token:ErrInvalidToken。 func TestVerify_Expired(t *testing.T) { _, pubPEM, iss, _, rev := testKey(t, 1*time.Second) // 1s 有效期 access, _, _ := iss.Issue(context.Background(), Claims{Sub: "uid-1"}) time.Sleep(1100 * time.Millisecond) if _, err := Verify(context.Background(), []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrInvalidToken) { t.Fatalf("期望 ErrInvalidToken(过期),实际 %v", err) } } // ---- Rotate ---- // TestRotate_Success 旧 refresh 换新对,claims 原样带入。 func TestRotate_Success(t *testing.T) { _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute) ctx := context.Background() _, oldRefresh, err := iss.Issue(ctx, Claims{Sub: "uid-1", Roles: []string{"teacher"}, InstitutionID: 11, CampusID: 3}) if err != nil { t.Fatalf("Issue: %v", err) } newAccess, newRefresh, err := iss.Rotate(ctx, oldRefresh) if err != nil { t.Fatalf("Rotate: %v", err) } if newAccess == "" || newRefresh == "" { t.Fatal("新 token 对缺失") } claims, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", newAccess) if err != nil { t.Fatalf("验新 access: %v", err) } if claims.Sub != "uid-1" || claims.InstitutionID != 11 { t.Fatalf("claims=%+v(期望 uid-1/11)", claims) } } // TestRotate_OldInvalidAfter 旧 refresh 轮换后立即失效(防重放)。 func TestRotate_OldInvalidAfter(t *testing.T) { _, _, iss, _, _ := testKey(t, 30*time.Minute) ctx := context.Background() _, oldRefresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"}) if _, _, err := iss.Rotate(ctx, oldRefresh); err != nil { t.Fatalf("首次 Rotate: %v", err) } if _, _, err := iss.Rotate(ctx, oldRefresh); !stderrors.Is(err, ErrRefreshInvalid) { t.Fatalf("期望旧串二次 Rotate 返回 ErrRefreshInvalid,实际 %v", err) } } // TestRotate_BadFormat 非 64hex:ErrRefreshInvalid。 func TestRotate_BadFormat(t *testing.T) { _, _, iss, _, _ := testKey(t, 30*time.Minute) if _, _, err := iss.Rotate(context.Background(), "too-short"); !stderrors.Is(err, ErrRefreshInvalid) { t.Fatalf("期望 ErrRefreshInvalid(格式非法),实际 %v", err) } } // TestRotate_NotInWhitelist 合法格式但白名单无:ErrRefreshInvalid。 func TestRotate_NotInWhitelist(t *testing.T) { _, _, iss, _, _ := testKey(t, 30*time.Minute) if _, _, err := iss.Rotate(context.Background(), fmt.Sprintf("%064x", 0)); !stderrors.Is(err, ErrRefreshInvalid) { t.Fatalf("期望 ErrRefreshInvalid(不在白名单),实际 %v", err) } } // ---- DeleteRefreshToken ---- // TestDeleteRefreshToken_Success 合法 64hex:删白名单键,再 Rotate 失败。 func TestDeleteRefreshToken_Success(t *testing.T) { _, _, iss, _, _ := testKey(t, 30*time.Minute) ctx := context.Background() _, refresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"}) if err := iss.DeleteRefreshToken(ctx, refresh); err != nil { t.Fatalf("DeleteRefreshToken: %v", err) } if _, _, err := iss.Rotate(ctx, refresh); !stderrors.Is(err, ErrRefreshInvalid) { t.Fatalf("期望删除后 Rotate 返回 ErrRefreshInvalid,实际 %v", err) } } // TestDeleteRefreshToken_BadFormat 非 64hex:报错。 func TestDeleteRefreshToken_BadFormat(t *testing.T) { _, _, iss, _, _ := testKey(t, 30*time.Minute) if err := iss.DeleteRefreshToken(context.Background(), "bad"); err == nil { t.Fatal("期望非法格式报错,实际 nil") } } // TestDeleteRefreshToken_Idempotent 键已删/不存在:幂等不报错。 func TestDeleteRefreshToken_Idempotent(t *testing.T) { _, _, iss, _, _ := testKey(t, 30*time.Minute) ctx := context.Background() _, refresh, _ := iss.Issue(ctx, Claims{Sub: "uid-1"}) if err := iss.DeleteRefreshToken(ctx, refresh); err != nil { t.Fatalf("首次删: %v", err) } if err := iss.DeleteRefreshToken(ctx, refresh); err != nil { t.Fatalf("二次删应幂等不报错,实际 %v", err) } } // ---- RevokeAll ---- // TestRevokeAll 写黑名单(jti 维度),后续 Verify 命中 ErrRevoked。 func TestRevokeAll(t *testing.T) { _, pubPEM, iss, _, rev := testKey(t, 30*time.Minute) ctx := context.Background() access, _, _ := iss.Issue(ctx, Claims{Sub: "uid-1"}) claims, _ := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access) if err := iss.RevokeAll(ctx, claims.ID, time.Minute); err != nil { t.Fatalf("RevokeAll: %v", err) } if _, err := Verify(ctx, []string{pubPEM}, rev, "https://unified.test", access); !stderrors.Is(err, ErrRevoked) { t.Fatalf("期望吊销后 Verify 返回 ErrRevoked,实际 %v", err) } }