526 lines
16 KiB
Go
526 lines
16 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"oci-portal/internal/model"
|
|
)
|
|
|
|
// loginSession 用密码登录建一条会话,返回令牌。
|
|
func loginSession(t *testing.T, auth *AuthService, ip, ua string) string {
|
|
t.Helper()
|
|
token, _, err := auth.Login(context.Background(), "admin", "pass123", "",
|
|
SessionMeta{ClientIP: ip, UserAgent: ua})
|
|
if err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
return token
|
|
}
|
|
|
|
// mustProof 从有效令牌解析版本/jti 快照。
|
|
func mustProof(t *testing.T, auth *AuthService, token string) TokenProof {
|
|
t.Helper()
|
|
_, proof, err := auth.ParseTokenProof(context.Background(), token)
|
|
if err != nil {
|
|
t.Fatalf("parse token proof: %v", err)
|
|
}
|
|
return proof
|
|
}
|
|
|
|
func sessionRows(t *testing.T, auth *AuthService) []model.UserSession {
|
|
t.Helper()
|
|
rows := []model.UserSession{}
|
|
if err := auth.db.Order("id").Find(&rows).Error; err != nil {
|
|
t.Fatalf("load sessions: %v", err)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func TestSessionRecordedOnLogin(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
loginSession(t, auth, "10.9.0.1", "TestAgent/1.0")
|
|
rows := sessionRows(t, auth)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("sessions = %d, want 1", len(rows))
|
|
}
|
|
r := rows[0]
|
|
if r.Method != "password" || r.ClientIP != "10.9.0.1" || r.UserAgent != "TestAgent/1.0" {
|
|
t.Errorf("row = %+v, want password/10.9.0.1/TestAgent", r)
|
|
}
|
|
if r.TokenID == "" || r.ExpiresAt.Before(time.Now()) {
|
|
t.Errorf("row token/expiry invalid: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestSessionRenewContinuity(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
token := loginSession(t, auth, "10.9.0.2", "UA")
|
|
before := sessionRows(t, auth)[0]
|
|
|
|
// 敏感变更路径:版本递增 + 换发接续(RevokeSessions 即 bump+renew)
|
|
fresh, _, err := auth.RevokeSessions(ctx, "admin", token, SessionMeta{ClientIP: "10.9.0.2", UserAgent: "UA"}, mustProof(t, auth, token))
|
|
if err != nil {
|
|
t.Fatalf("RevokeSessions: %v", err)
|
|
}
|
|
rows := sessionRows(t, auth)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("sessions after renew = %d, want 1 (continuity, no new row)", len(rows))
|
|
}
|
|
after := rows[0]
|
|
if after.ID != before.ID || after.TokenID != before.TokenID {
|
|
t.Errorf("renew should keep row id and jti: before %+v after %+v", before, after)
|
|
}
|
|
if after.Method != "password" {
|
|
t.Errorf("method = %q, want inherited password", after.Method)
|
|
}
|
|
if _, err := auth.ParseToken(ctx, token); err == nil {
|
|
t.Error("old token still valid after version bump")
|
|
}
|
|
if _, err := auth.ParseToken(ctx, fresh); err != nil {
|
|
t.Errorf("fresh token invalid: %v", err)
|
|
}
|
|
// 列表只剩接续会话且标记 current
|
|
list, err := auth.ListSessions(ctx, "admin", fresh)
|
|
if err != nil || len(list) != 1 || !list[0].Current {
|
|
t.Errorf("ListSessions = (%+v, %v), want single current session", list, err)
|
|
}
|
|
}
|
|
|
|
func TestSessionRevokeSingle(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
token1 := loginSession(t, auth, "10.9.0.3", "Laptop")
|
|
token2 := loginSession(t, auth, "10.9.0.4", "Phone")
|
|
// token2 已被 ParseToken 校验过(节流缓存生效)后再撤销,验证缓存被清、即时失效
|
|
if _, err := auth.ParseToken(ctx, token2); err != nil {
|
|
t.Fatalf("token2 parse: %v", err)
|
|
}
|
|
list, err := auth.ListSessions(ctx, "admin", token1)
|
|
if err != nil || len(list) != 2 {
|
|
t.Fatalf("ListSessions = (%d, %v), want 2", len(list), err)
|
|
}
|
|
var otherID uint
|
|
for _, it := range list {
|
|
if it.Current {
|
|
continue
|
|
}
|
|
otherID = it.ID
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
id uint
|
|
wantErr error
|
|
}{
|
|
{name: "撤销当前会话被拒", id: currentSessionID(t, list), wantErr: ErrSessionCurrent},
|
|
{name: "撤销其他会话成功", id: otherID},
|
|
{name: "重复撤销已不可见", id: otherID, wantErr: gorm.ErrRecordNotFound},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := auth.RevokeSession(ctx, "admin", token1, tt.id)
|
|
if tt.wantErr != nil {
|
|
if !errors.Is(err, tt.wantErr) && !(tt.wantErr == gorm.ErrRecordNotFound && err != nil) {
|
|
t.Fatalf("RevokeSession err = %v, want %v", err, tt.wantErr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("RevokeSession: %v", err)
|
|
}
|
|
})
|
|
}
|
|
if _, err := auth.ParseToken(ctx, token2); err == nil {
|
|
t.Error("revoked session token still valid")
|
|
}
|
|
if _, err := auth.ParseToken(ctx, token1); err != nil {
|
|
t.Errorf("current token broken by revoking another session: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionRevokeTombstone(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
token1 := loginSession(t, auth, "10.9.0.7", "Laptop")
|
|
token2 := loginSession(t, auth, "10.9.0.8", "Phone")
|
|
list, err := auth.ListSessions(ctx, "admin", token1)
|
|
if err != nil || len(list) != 2 {
|
|
t.Fatalf("ListSessions = (%d, %v), want 2", len(list), err)
|
|
}
|
|
var otherID uint
|
|
for _, it := range list {
|
|
if !it.Current {
|
|
otherID = it.ID
|
|
}
|
|
}
|
|
if err := auth.RevokeSession(ctx, "admin", token1, otherID); err != nil {
|
|
t.Fatalf("RevokeSession: %v", err)
|
|
}
|
|
// 模拟「校验读库通过→撤销→校验回写 seen」的竞态:手工回写节流缓存,
|
|
// 撤销负缓存必须仍然盖过它,令牌不得复活
|
|
jti := auth.signedJti(token2)
|
|
auth.seen.Set("seen|"+jti, struct{}{}, sessionSeenTTL)
|
|
if _, err := auth.ParseToken(ctx, token2); err == nil {
|
|
t.Error("revoked token revived by racing seen-cache write")
|
|
}
|
|
}
|
|
|
|
// TestSensitiveOpStaleProof 验证在途绕过防线:敏感请求鉴权后挂起,期间发生
|
|
// 「撤销全部」(版本递增),恢复后的敏感事务复核快照失败,拒绝执行、不发新令牌。
|
|
// 同一防线也使并发敏感操作串行化(后到者复核失败)。
|
|
func TestSensitiveOpStaleProof(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
tokenA := loginSession(t, auth, "10.9.1.1", "A")
|
|
proofA := mustProof(t, auth, tokenA)
|
|
// 另一设备撤销全部:版本递增,tokenA 的快照随之过期
|
|
tokenB := loginSession(t, auth, "10.9.1.2", "B")
|
|
if _, _, err := auth.RevokeSessions(ctx, "admin", tokenB, SessionMeta{ClientIP: "10.9.1.2"}, mustProof(t, auth, tokenB)); err != nil {
|
|
t.Fatalf("RevokeSessions: %v", err)
|
|
}
|
|
// 挂起的旧请求恢复:携带过期快照的敏感操作必须被拒
|
|
if _, _, err := auth.RevokeSessions(ctx, "admin", tokenA, SessionMeta{ClientIP: "10.9.1.1"}, proofA); !errors.Is(err, ErrTokenStale) {
|
|
t.Fatalf("stale-proof revoke err = %v, want ErrTokenStale", err)
|
|
}
|
|
}
|
|
|
|
// TestSensitiveOpRevokedJtiProof 验证快照的 jti 维度:定点撤销(不递增版本)
|
|
// 同样令该令牌的在途敏感请求失效。
|
|
func TestSensitiveOpRevokedJtiProof(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
token1 := loginSession(t, auth, "10.9.2.1", "Laptop")
|
|
token2 := loginSession(t, auth, "10.9.2.2", "Phone")
|
|
proof2 := mustProof(t, auth, token2)
|
|
list, err := auth.ListSessions(ctx, "admin", token1)
|
|
if err != nil || len(list) != 2 {
|
|
t.Fatalf("ListSessions = (%d, %v), want 2", len(list), err)
|
|
}
|
|
for _, it := range list {
|
|
if !it.Current {
|
|
if err := auth.RevokeSession(ctx, "admin", token1, it.ID); err != nil {
|
|
t.Fatalf("RevokeSession: %v", err)
|
|
}
|
|
}
|
|
}
|
|
// token2 已被定点撤销(版本未变):其在途敏感请求恢复后必须被拒
|
|
if _, _, err := auth.RevokeSessions(ctx, "admin", token2, SessionMeta{ClientIP: "10.9.2.2"}, proof2); !errors.Is(err, ErrTokenStale) {
|
|
t.Fatalf("revoked-jti proof err = %v, want ErrTokenStale", err)
|
|
}
|
|
}
|
|
|
|
func TestCheckedSessionCannotRenewAfterTargetedRevoke(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
stale := loginSession(t, auth, "10.9.3.1", "stale")
|
|
current := loginSession(t, auth, "10.9.3.2", "current")
|
|
proof := mustProof(t, auth, stale)
|
|
assertProofCurrentTx(t, auth, proof)
|
|
staleID := otherSessionID(t, auth, current)
|
|
if err := auth.bumpTokenVersion(ctx, "admin"); err != nil {
|
|
t.Fatalf("simulate sensitive mutation: %v", err)
|
|
}
|
|
if err := auth.RevokeSession(ctx, "admin", current, staleID); err != nil {
|
|
t.Fatalf("RevokeSession: %v", err)
|
|
}
|
|
if _, _, err := auth.RenewToken(
|
|
ctx, "admin", stale, SessionMeta{ClientIP: "10.9.3.1"},
|
|
); !errors.Is(err, ErrTokenStale) {
|
|
t.Fatalf("RenewToken err = %v, want ErrTokenStale", err)
|
|
}
|
|
if got := len(sessionRows(t, auth)); got != 2 {
|
|
t.Fatalf("session rows = %d, want 2 (不得 fallback CREATE)", got)
|
|
}
|
|
}
|
|
|
|
func otherSessionID(t *testing.T, auth *AuthService, current string) uint {
|
|
t.Helper()
|
|
for _, item := range mustSessions(t, auth, current) {
|
|
if !item.Current {
|
|
return item.ID
|
|
}
|
|
}
|
|
t.Fatal("no other session")
|
|
return 0
|
|
}
|
|
|
|
func TestLegacyLogoutBlocksInflightProofAndRenew(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
token, _, _, err := auth.signToken("admin", 0)
|
|
if err != nil {
|
|
t.Fatalf("signToken: %v", err)
|
|
}
|
|
proof := mustProof(t, auth, token)
|
|
assertProofCurrentTx(t, auth, proof)
|
|
auth.Logout(ctx, token)
|
|
assertProofStaleTx(t, auth, proof)
|
|
if err := auth.bumpTokenVersion(ctx, "admin"); err != nil {
|
|
t.Fatalf("simulate already committed mutation: %v", err)
|
|
}
|
|
if _, _, err := auth.RenewToken(
|
|
ctx, "admin", token, SessionMeta{ClientIP: "10.9.4.1"},
|
|
); !errors.Is(err, ErrTokenStale) {
|
|
t.Fatalf("RenewToken err = %v, want ErrTokenStale", err)
|
|
}
|
|
if got := len(sessionRows(t, auth)); got != 0 {
|
|
t.Fatalf("session rows = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
type logoutAfterRenameCase struct {
|
|
name string
|
|
legacy bool
|
|
meta SessionMeta
|
|
}
|
|
|
|
func TestLogoutOldTokenAfterCredentialRename(t *testing.T) {
|
|
tests := []logoutAfterRenameCase{
|
|
{name: "recorded session", meta: SessionMeta{ClientIP: "10.9.5.1", UserAgent: "recorded"}},
|
|
{name: "legacy session without row", legacy: true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testLogoutOldTokenAfterCredentialRename(t, tt)
|
|
})
|
|
}
|
|
}
|
|
|
|
func testLogoutOldTokenAfterCredentialRename(t *testing.T, tt logoutAfterRenameCase) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
old, oldExpires := tokenForRenewalTest(t, auth, tt.legacy)
|
|
fresh, expires := renewAfterCredentialRename(t, auth, old, tt.meta)
|
|
if auth.signedJti(old) != auth.signedJti(fresh) {
|
|
t.Fatal("renewal changed jti; logout lineage would be lost")
|
|
}
|
|
if tt.legacy && len(sessionRows(t, auth)) != 0 {
|
|
t.Fatal("legacy renewal unexpectedly created a session row")
|
|
}
|
|
auth.Logout(context.Background(), old)
|
|
if _, err := auth.ParseToken(context.Background(), fresh); err == nil {
|
|
t.Fatal("fresh token survived logout of its pre-renewal token")
|
|
}
|
|
if tt.legacy {
|
|
assertTombstoneCovers(t, auth, old, oldExpires, expires)
|
|
return
|
|
}
|
|
assertSessionJTIRevoked(t, auth, old)
|
|
}
|
|
|
|
func renewAfterCredentialRename(
|
|
t *testing.T, auth *AuthService, old string, meta SessionMeta,
|
|
) (string, time.Time) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
finalName, err := auth.UpdateCredentials(ctx, "admin", UpdateCredentialsInput{
|
|
NewUsername: "root", CurrentPassword: "pass123",
|
|
}, mustProof(t, auth, old))
|
|
if err != nil {
|
|
t.Fatalf("UpdateCredentials: %v", err)
|
|
}
|
|
fresh, expires, err := auth.RenewToken(ctx, finalName, old, meta)
|
|
if err != nil {
|
|
t.Fatalf("RenewToken: %v", err)
|
|
}
|
|
return fresh, expires
|
|
}
|
|
|
|
func assertSessionJTIRevoked(t *testing.T, auth *AuthService, token string) {
|
|
t.Helper()
|
|
var row model.UserSession
|
|
err := auth.db.Where("token_id = ?", auth.signedJti(token)).First(&row).Error
|
|
if err != nil {
|
|
t.Fatalf("find session: %v", err)
|
|
}
|
|
if row.RevokedAt == nil {
|
|
t.Fatal("renamed user's session row was not revoked")
|
|
}
|
|
}
|
|
|
|
func assertTombstoneCovers(
|
|
t *testing.T, auth *AuthService, token string, oldExpires, freshExpires time.Time,
|
|
) {
|
|
t.Helper()
|
|
jti := auth.signedJti(token)
|
|
auth.revokedJti.mu.RLock()
|
|
tombstoneExpires, ok := auth.revokedJti.m[jti]
|
|
auth.revokedJti.mu.RUnlock()
|
|
if !ok {
|
|
t.Fatal("logout jti tombstone missing")
|
|
}
|
|
requiredUntil := oldExpires.Truncate(time.Second).Add(tokenTTL)
|
|
if tombstoneExpires.Before(requiredUntil) || tombstoneExpires.Before(freshExpires) {
|
|
t.Fatalf("tombstone expires %v before required horizon %v", tombstoneExpires, requiredUntil)
|
|
}
|
|
}
|
|
|
|
func tokenForRenewalTest(t *testing.T, auth *AuthService, legacy bool) (string, time.Time) {
|
|
t.Helper()
|
|
if !legacy {
|
|
token, expires, err := auth.Login(context.Background(), "admin", "pass123", "",
|
|
SessionMeta{ClientIP: "10.9.5.1", UserAgent: "recorded"})
|
|
if err != nil {
|
|
t.Fatalf("Login: %v", err)
|
|
}
|
|
return token, expires
|
|
}
|
|
token, expires, _, err := auth.signToken("admin", 0)
|
|
if err != nil {
|
|
t.Fatalf("signToken: %v", err)
|
|
}
|
|
return token, expires
|
|
}
|
|
|
|
func assertProofCurrentTx(t *testing.T, auth *AuthService, proof TokenProof) {
|
|
t.Helper()
|
|
err := auth.db.Transaction(func(tx *gorm.DB) error {
|
|
user, err := lockUserForAuthChange(tx, "admin")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return auth.ensureTokenCurrentTx(tx, user, proof)
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("proof should be current: %v", err)
|
|
}
|
|
}
|
|
|
|
func assertProofStaleTx(t *testing.T, auth *AuthService, proof TokenProof) {
|
|
t.Helper()
|
|
err := auth.db.Transaction(func(tx *gorm.DB) error {
|
|
user, lockErr := lockUserForAuthChange(tx, "admin")
|
|
if lockErr != nil {
|
|
return lockErr
|
|
}
|
|
return auth.ensureTokenCurrentTx(tx, user, proof)
|
|
})
|
|
if !errors.Is(err, ErrTokenStale) {
|
|
t.Fatalf("proof err = %v, want ErrTokenStale", err)
|
|
}
|
|
}
|
|
|
|
func mustSessions(t *testing.T, auth *AuthService, current string) []SessionInfo {
|
|
t.Helper()
|
|
items, err := auth.ListSessions(context.Background(), "admin", current)
|
|
if err != nil {
|
|
t.Fatalf("ListSessions: %v", err)
|
|
}
|
|
return items
|
|
}
|
|
|
|
// TestTombstoneNoEviction 验证撤销负缓存无容量上限:大量撤销标记全部存活,
|
|
// 不存在「满载淘汰导致已撤销令牌复活」。
|
|
func TestTombstoneNoEviction(t *testing.T) {
|
|
ts := newJtiTombstones()
|
|
for i := 0; i < 600; i++ {
|
|
ts.put(fmt.Sprintf("jti-%d", i), time.Minute)
|
|
}
|
|
for i := 0; i < 600; i++ {
|
|
if !ts.has(fmt.Sprintf("jti-%d", i)) {
|
|
t.Fatalf("tombstone jti-%d evicted", i)
|
|
}
|
|
}
|
|
if ts.has("jti-none") {
|
|
t.Error("unknown jti reported revoked")
|
|
}
|
|
}
|
|
|
|
// currentSessionID 取列表中 current 条目的 ID。
|
|
func currentSessionID(t *testing.T, list []SessionInfo) uint {
|
|
t.Helper()
|
|
for _, it := range list {
|
|
if it.Current {
|
|
return it.ID
|
|
}
|
|
}
|
|
t.Fatal("no current session in list")
|
|
return 0
|
|
}
|
|
|
|
func TestSessionLegacyTokenAllowed(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
// 直签令牌(无会话行)模拟升级前存量令牌:仍可通过校验
|
|
token, _, _, err := auth.signToken("admin", 0)
|
|
if err != nil {
|
|
t.Fatalf("signToken: %v", err)
|
|
}
|
|
if _, err := auth.ParseToken(context.Background(), token); err != nil {
|
|
t.Errorf("legacy token rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSessionLogoutMarksRow(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
token := loginSession(t, auth, "10.9.0.5", "UA")
|
|
auth.Logout(ctx, token)
|
|
rows := sessionRows(t, auth)
|
|
if len(rows) != 1 || rows[0].RevokedAt == nil {
|
|
t.Errorf("logout should mark session revoked: %+v", rows)
|
|
}
|
|
}
|
|
|
|
func TestSessionCleanup(t *testing.T) {
|
|
auth := newTestAuth(t)
|
|
if err := auth.EnsureAdmin("admin", "pass123"); err != nil {
|
|
t.Fatalf("EnsureAdmin: %v", err)
|
|
}
|
|
ctx := context.Background()
|
|
now := time.Now()
|
|
old := now.Add(-8 * 24 * time.Hour)
|
|
seed := []model.UserSession{
|
|
{UserID: 1, TokenID: "expired", ExpiresAt: now.Add(-25 * time.Hour), LastSeenAt: old},
|
|
{UserID: 1, TokenID: "revoked-old", ExpiresAt: now.Add(time.Hour), RevokedAt: &old, LastSeenAt: old},
|
|
{UserID: 1, TokenID: "alive", ExpiresAt: now.Add(time.Hour), LastSeenAt: now},
|
|
}
|
|
for i := range seed {
|
|
if err := auth.db.Create(&seed[i]).Error; err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
}
|
|
auth.cleanupSessionsOnce(ctx)
|
|
rows := sessionRows(t, auth)
|
|
if len(rows) != 1 || rows[0].TokenID != "alive" {
|
|
t.Errorf("after cleanup rows = %+v, want only alive", rows)
|
|
}
|
|
}
|