滑动续期、网络错误归类、API Key 激活乐观锁与生效提示

This commit is contained in:
2026-08-11 11:45:03 +08:00
parent 23b2820101
commit e63af49831
21 changed files with 679 additions and 15 deletions
+122
View File
@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"oci-portal/internal/model"
@@ -523,3 +524,124 @@ func TestSessionCleanup(t *testing.T) {
t.Errorf("after cleanup rows = %+v, want only alive", rows)
}
}
// signShortToken 用服务同款密钥手工签指定 TTL 的令牌,构造临近过期态。
func signShortToken(t *testing.T, auth *AuthService, username string, ver uint, jti string, ttl time.Duration) string {
t.Helper()
claims := &authClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: username,
ID: jti,
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)),
},
Ver: ver,
}
tok, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(auth.jwtSecret)
if err != nil {
t.Fatalf("sign short token: %v", err)
}
return tok
}
func renewalFixture(t *testing.T) (*AuthService, string, TokenProof) {
t.Helper()
auth := newTestAuth(t)
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
t.Fatalf("EnsureAdmin: %v", err)
}
ctx := context.Background()
loginTok, _, err := auth.Login(ctx, "admin", "pass123", "",
SessionMeta{ClientIP: "127.0.0.1", UserAgent: "t"})
if err != nil {
t.Fatalf("Login: %v", err)
}
_, proof, err := auth.ParseTokenProof(ctx, loginTok)
if err != nil {
t.Fatalf("ParseTokenProof: %v", err)
}
return auth, loginTok, proof
}
func setSessionExpiry(t *testing.T, auth *AuthService, jti string, expires time.Time) model.UserSession {
t.Helper()
if err := auth.db.Model(&model.UserSession{}).Where("token_id = ?", jti).
UpdateColumn("expires_at", expires).Error; err != nil {
t.Fatalf("set session expiry: %v", err)
}
var row model.UserSession
if err := auth.db.Where("token_id = ?", jti).First(&row).Error; err != nil {
t.Fatalf("find session row: %v", err)
}
return row
}
func TestMaybeRenewEligibility(t *testing.T) {
auth, loginTok, _ := renewalFixture(t)
tests := []struct {
name string
token string
}{
{name: "剩余时间高于阈值", token: loginTok},
{name: "非法令牌", token: "not.a.token"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, _, ok := auth.MaybeRenew(context.Background(), tt.token); ok {
t.Error("MaybeRenew ok = true, want false")
}
})
}
}
func TestMaybeRenewExtendsSession(t *testing.T) {
auth, _, proof := renewalFixture(t)
before := setSessionExpiry(t, auth, proof.Jti, time.Now().Add(time.Hour))
short := signShortToken(t, auth, "admin", proof.Ver, proof.Jti, time.Hour)
renewed, expires, ok := auth.MaybeRenew(context.Background(), short)
if !ok {
t.Fatal("剩余 1h 的令牌应换发")
}
_, newProof, err := auth.ParseTokenProof(context.Background(), renewed)
if err != nil {
t.Fatalf("ParseTokenProof(renewed): %v", err)
}
if newProof.Jti != proof.Jti || newProof.Ver != proof.Ver {
t.Errorf("proof = %+v, want %+v", newProof, proof)
}
var after model.UserSession
if err := auth.db.Where("token_id = ?", proof.Jti).First(&after).Error; err != nil {
t.Fatalf("reload session row: %v", err)
}
if delta := after.ExpiresAt.Sub(before.ExpiresAt); delta < 22*time.Hour {
t.Errorf("session expiry delta = %v, want >= 22h", delta)
}
if gap := after.ExpiresAt.Sub(expires); gap < -time.Second || gap > time.Second {
t.Errorf("session expiry = %v, token expiry = %v", after.ExpiresAt, expires)
}
}
func TestMaybeRenewedTokenFollowsSessionRevocation(t *testing.T) {
auth, loginTok, proof := renewalFixture(t)
short := signShortToken(t, auth, "admin", proof.Ver, proof.Jti, time.Hour)
renewed, _, ok := auth.MaybeRenew(context.Background(), short)
if !ok {
t.Fatal("剩余 1h 的令牌应换发")
}
auth.Logout(context.Background(), loginTok)
if _, err := auth.ParseToken(context.Background(), renewed); err == nil {
t.Error("会话撤销后换发令牌仍有效, want 失效")
}
}
func TestMaybeRenewUpdateFailureDoesNotIssueToken(t *testing.T) {
auth, _, proof := renewalFixture(t)
short := signShortToken(t, auth, "admin", proof.Ver, proof.Jti, time.Hour)
if err := auth.db.Migrator().DropTable(&model.UserSession{}); err != nil {
t.Fatalf("drop sessions table: %v", err)
}
token, expires, ok := auth.MaybeRenew(context.Background(), short)
if ok || token != "" || !expires.IsZero() {
t.Errorf("MaybeRenew = (%q, %v, %v), want empty result", token, expires, ok)
}
}