Scert/backend/internal/bootstrap/database_lifecycle_test.go
xuans 1620a83461 补充数据库生命周期专项回归
增加请求取消、Claim 清理重试、批量清理容量和并发竞争等数据库验证生命周期测试,作为 P0-10 待复审证据。
2026-09-30 13:37:46 +08:00

250 lines
8.0 KiB
Go

package bootstrap
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type lifecycleDatabaseStore struct {
memoryDatabaseStore
afterPut func()
beforeDelete func(context.Context) error
deleteCalls atomic.Int32
}
func (s *lifecycleDatabaseStore) Put(ctx context.Context, reference string, values map[string]string) error {
err := s.memoryDatabaseStore.Put(ctx, reference, values)
if s.afterPut != nil {
s.afterPut()
}
return err
}
func (s *lifecycleDatabaseStore) Delete(ctx context.Context, reference string) error {
s.deleteCalls.Add(1)
if s.beforeDelete != nil {
if err := s.beforeDelete(ctx); err != nil {
return err
}
}
return s.memoryDatabaseStore.Delete(ctx, reference)
}
func databaseLifecycleInput() DatabaseInput {
return DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "owner", Password: "test-password",
}
}
func TestDatabaseCancellationDoesNotPublishSession(t *testing.T) {
for _, phase := range []string{"before", "after_put", "after_verify"} {
t.Run(phase, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
store := &lifecycleDatabaseStore{}
manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store)
calls := 0
manager.verify = func(context.Context, config.DatabaseConfig) error {
calls++
if phase == "after_verify" {
cancel()
}
return nil
}
if phase == "before" {
cancel()
} else if phase == "after_put" {
store.afterPut = cancel
}
result, err := manager.Verify(ctx, databaseLifecycleInput())
if !errors.Is(err, context.Canceled) || result.VerificationToken != "" {
t.Fatalf("取消请求不得发布令牌:%v", err)
}
if phase != "after_verify" && calls != 0 {
t.Fatal("已取消请求仍执行了数据库连接验证")
}
if len(store.values) != 0 || len(manager.sessions) != 0 || manager.inFlight != 0 {
t.Fatal("取消请求遗留了凭据、会话或并发占位")
}
})
}
}
func TestDatabaseClaimCleanupFailureRetriesThroughPublicOperation(t *testing.T) {
store := &memoryDatabaseStore{deleteFailures: 1}
manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
result, err := manager.Verify(t.Context(), databaseLifecycleInput())
if err != nil {
t.Fatal(err)
}
_, cleanup, err := manager.Claim(result.VerificationToken)
if err != nil {
t.Fatal(err)
}
if err := cleanup(); err == nil || len(manager.pendingCleanup) != 1 {
t.Fatal("事务回收失败必须保留待清理引用")
}
manager.DiscardAll()
if len(store.values) != 0 || len(manager.pendingCleanup) != 0 {
t.Fatal("后续公开操作未重试事务遗留凭据")
}
if _, err := manager.Resolve(result.VerificationToken); err == nil {
t.Fatal("事务已领取的令牌不得因清理重试重新生效")
}
}
func TestDatabaseBatchCleanupProtectsExpiredFormalCredential(t *testing.T) {
store := &memoryDatabaseStore{}
manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
result, err := manager.Verify(t.Context(), databaseLifecycleInput())
if err != nil {
t.Fatal(err)
}
manager.now = func() time.Time { return result.ExpiresAt }
manager.DiscardAllExcept(result.CredentialRef)
if _, exists := store.values[result.CredentialRef]; !exists {
t.Fatal("批量清理错误删除了过期令牌关联的正式凭据")
}
if len(manager.sessions) != 0 || len(manager.expiredSession) != 0 {
t.Fatal("保护正式凭据不应保留初始化令牌")
}
}
func TestDatabaseCleaningSessionsReserveCapacity(t *testing.T) {
for _, mode := range []string{"expiry", "discard", "batch"} {
t.Run(mode, func(t *testing.T) {
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
var startOnce, releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
store := &lifecycleDatabaseStore{beforeDelete: func(ctx context.Context) error {
startOnce.Do(func() { close(started) })
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}}
manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store)
var verifyCalls atomic.Int32
manager.verify = func(context.Context, config.DatabaseConfig) error { verifyCalls.Add(1); return nil }
now := time.Now().UTC()
manager.now = func() time.Time { return now }
for i := range databaseVerificationCapacity {
expires := now.Add(time.Hour)
if mode == "expiry" && i == 0 {
expires = now
}
manager.sessions[fmt.Sprint(i)] = databaseVerificationSession{
CredentialRef: fmt.Sprint("credential-", i), ExpiresAt: expires,
}
}
go func() {
defer close(done)
switch mode {
case "expiry":
_, _ = manager.Resolve("0")
case "discard":
_ = manager.Discard("0")
case "batch":
manager.DiscardAll()
}
}()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("凭据删除未开始")
}
if _, err := manager.Verify(t.Context(), DatabaseInput{Driver: "sqlite"}); err == nil {
t.Fatal("清理中的会话未计入容量,错误接受了新验证")
}
if verifyCalls.Load() != 0 {
t.Fatal("容量已满时仍进行了数据库验证")
}
unblock()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("清理无法完成")
}
if len(manager.expiredSession) != 0 {
t.Fatal("清理成功后仍保留容量占位")
}
})
}
}
func TestDatabaseBatchDoesNotTakeOverConcurrentExpiryCleanup(t *testing.T) {
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
var startOnce, releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
defer unblock()
store := &lifecycleDatabaseStore{beforeDelete: func(ctx context.Context) error {
startOnce.Do(func() { close(started) })
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}}
manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
result, err := manager.Verify(t.Context(), databaseLifecycleInput())
if err != nil {
t.Fatal(err)
}
manager.now = func() time.Time { return result.ExpiresAt }
go func() { defer close(done); _, _ = manager.Resolve(result.VerificationToken) }()
select {
case <-started:
case <-time.After(5 * time.Second):
t.Fatal("过期清理未开始")
}
batchDone := make(chan struct{})
go func() { defer close(batchDone); manager.DiscardAll() }()
select {
case <-batchDone:
case <-time.After(5 * time.Second):
t.Fatal("批量清理重复接管了正在删除的过期会话")
}
unblock()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("过期清理未结束")
}
if store.deleteCalls.Load() != 1 || len(manager.expiredSession) != 0 {
t.Fatal("单个过期会话被重复删除或遗留清理状态")
}
}
func TestDatabasePendingCleanupCountsTowardCapacity(t *testing.T) {
store := &lifecycleDatabaseStore{beforeDelete: func(context.Context) error { return errors.New("测试删除失败") }}
manager := NewDatabaseVerificationManager(config.Default(t.TempDir()), store)
manager.verify = func(context.Context, config.DatabaseConfig) error {
t.Fatal("待回收凭据占满容量时不应连接数据库")
return nil
}
for i := range databaseVerificationCapacity {
manager.pendingCleanup[fmt.Sprint("pending-", i)] = struct{}{}
}
if _, err := manager.Verify(t.Context(), DatabaseInput{Driver: "sqlite"}); err == nil {
t.Fatal("未回收凭据未计入验证容量")
}
if manager.inFlight != 0 || len(manager.pendingCleanup) != databaseVerificationCapacity {
t.Fatal("拒绝验证时破坏了待清理状态")
}
}