284 lines
9.0 KiB
Go
284 lines
9.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 TestDatabaseBatchTakesOverFailedExpiryCleanup(t *testing.T) {
|
|
store := &lifecycleDatabaseStore{}
|
|
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.mu.Lock()
|
|
entry := expiredDatabaseVerificationSession{
|
|
Token: result.VerificationToken,
|
|
Session: manager.sessions[result.VerificationToken],
|
|
}
|
|
delete(manager.sessions, result.VerificationToken)
|
|
manager.expiredSession[result.VerificationToken] = entry
|
|
manager.mu.Unlock()
|
|
|
|
manager.DiscardAll()
|
|
|
|
if store.deleteCalls.Load() != 1 {
|
|
t.Fatalf("expected one cleanup attempt, got %d", store.deleteCalls.Load())
|
|
}
|
|
if len(manager.expiredSession) != 0 || len(manager.expiredCleaning) != 0 {
|
|
t.Fatal("batch cleanup left expired state behind")
|
|
}
|
|
store.mu.Lock()
|
|
_, retained := store.values[result.CredentialRef]
|
|
store.mu.Unlock()
|
|
if retained {
|
|
t.Fatal("batch cleanup did not delete the credential")
|
|
}
|
|
}
|
|
|
|
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("拒绝验证时破坏了待清理状态")
|
|
}
|
|
}
|