Scert/backend/internal/bootstrap/database_test.go
xuans 42e03242a9 增加数据库生命周期回归测试
加入保存失败、单个回收失败、过期回收失败和批量回收失败恢复的回归覆盖,当前实现仍待修正测试发现的状态顺序问题。
2026-09-30 12:57:40 +08:00

453 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package bootstrap
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
type memoryDatabaseStore struct {
mu sync.Mutex
values map[string]map[string]string
putFailure bool
deleteFailures int
}
func (s *memoryDatabaseStore) Put(_ context.Context, reference string, value map[string]string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.values == nil {
s.values = make(map[string]map[string]string)
}
s.values[reference] = map[string]string{"username": value["username"], "password": value["password"]}
if s.putFailure {
return errors.New("simulated put failure")
}
return nil
}
func (s *memoryDatabaseStore) Get(_ context.Context, reference string) (map[string]string, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.values[reference], nil
}
func (s *memoryDatabaseStore) Delete(_ context.Context, reference string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.deleteFailures > 0 {
s.deleteFailures--
return errors.New("simulated delete failure")
}
delete(s.values, reference)
return nil
}
func TestBuildDatabaseConfigSupportsThreeStorageModesWithoutPersistingCredentials(t *testing.T) {
base := config.Default(t.TempDir())
base.Paths.Data = filepath.Join(t.TempDir(), "data")
tests := []struct {
name string
input DatabaseInput
}{
{name: "SQLite", input: DatabaseInput{Driver: "sqlite"}},
{name: "MySQL", input: DatabaseInput{Driver: "mysql", Host: "db.example.test", Database: "scert", Username: "scert_user", Password: "mysql-secret", TLSMode: "true"}},
{name: "PostgreSQL", input: DatabaseInput{Driver: "postgres", Host: "db.example.test", Database: "scert", Username: "scert_user", Password: "postgres-secret", TLSMode: "verify-full"}},
}
for _, item := range tests {
t.Run(item.name, func(t *testing.T) {
persisted, credentials, err := buildDatabaseConfig(base, item.input)
if err != nil {
t.Fatalf("构造数据库配置失败:%v", err)
}
if (item.input.Username != "" && strings.Contains(persisted.DSN, item.input.Username)) ||
(item.input.Password != "" && strings.Contains(persisted.DSN, item.input.Password)) {
t.Fatalf("持久化数据库配置泄露账号或密码:%s", persisted.DSN)
}
if item.input.Driver == "sqlite" {
if len(credentials) != 0 || persisted.DSN != filepath.Join(base.Paths.Data, "scert.db") {
t.Fatalf("SQLite 配置不正确:%+v credentials=%v", persisted, credentials)
}
return
}
if credentials["username"] != item.input.Username || credentials["password"] != item.input.Password {
t.Fatal("外部数据库凭据未单独返回")
}
})
}
}
func TestDatabaseVerificationStoresCredentialsAndConsumesTokenOnce(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(_ context.Context, runtime config.DatabaseConfig) error {
if !strings.Contains(runtime.DSN, "database-password") {
t.Fatalf("运行时连接信息未注入内存密码:%s", runtime.DSN)
}
return nil
}
result, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "true",
})
if err != nil {
t.Fatalf("数据库验证失败:%v", err)
}
if result.CredentialRef == "" || result.VerificationToken == "" {
t.Fatal("数据库验证结果缺少加密凭据引用或令牌")
}
if strings.Contains(result.Config.DSN, "scert_user") || strings.Contains(result.Config.DSN, "database-password") {
t.Fatalf("数据库验证响应泄露凭据:%+v", result.Config)
}
consumed, err := manager.Consume(result.VerificationToken, result.CredentialRef)
if err != nil || consumed.CredentialRef != result.CredentialRef {
t.Fatalf("消费数据库验证令牌失败:%v config=%+v", err, consumed)
}
if _, err := manager.Consume(result.VerificationToken, result.CredentialRef); err == nil {
t.Fatal("数据库验证令牌被重复消费")
}
store.mu.Lock()
_, retained := store.values[result.CredentialRef]
store.mu.Unlock()
if !retained {
t.Fatal("已消费的正式数据库凭据不应被验证令牌管理器删除")
}
}
func TestDatabaseVerificationFailureAndExpiryDeleteTemporaryCredentials(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return context.DeadlineExceeded }
if _, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "postgres", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "verify-full",
}); err == nil {
t.Fatal("数据库验证失败未返回错误")
}
store.mu.Lock()
count := len(store.values)
store.mu.Unlock()
if count != 0 {
t.Fatalf("失败的数据库验证遗留临时凭据:%d", count)
}
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
now := time.Now().UTC()
manager.now = func() time.Time { return now }
result, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
manager.now = func() time.Time { return now.Add(databaseVerificationLifetime + time.Second) }
if _, err := manager.Check(result.VerificationToken, result.CredentialRef); err == nil {
t.Fatal("过期数据库验证令牌未被拒绝")
}
store.mu.Lock()
_, exists := store.values[result.CredentialRef]
store.mu.Unlock()
if exists {
t.Fatal("过期数据库验证令牌的临时凭据未被删除")
}
}
func TestSQLiteRejectsUserControlledPathAndExternalFields(t *testing.T) {
base := config.Default(t.TempDir())
if _, _, err := buildDatabaseConfig(base, DatabaseInput{Driver: "sqlite", Database: "other.db"}); err == nil {
t.Fatal("SQLite 用户自定义路径未被拒绝")
}
}
func TestDatabaseVerificationDiscardOnlyRemovesSelectedToken(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
first, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "first_user", Password: "first-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
second, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "postgres", Host: "db.example.test", Database: "scert",
Username: "second_user", Password: "second-password", TLSMode: "verify-full",
})
if err != nil {
t.Fatal(err)
}
if err := manager.Discard(first.VerificationToken); err != nil {
t.Fatalf("精确回收数据库验证令牌失败:%v", err)
}
if _, err := manager.Check(first.VerificationToken, first.CredentialRef); err == nil {
t.Fatal("已回收的数据库验证令牌仍然有效")
}
if _, err := manager.Check(second.VerificationToken, second.CredentialRef); err != nil {
t.Fatalf("精确回收影响了其他数据库验证令牌:%v", err)
}
store.mu.Lock()
_, firstExists := store.values[first.CredentialRef]
_, secondExists := store.values[second.CredentialRef]
store.mu.Unlock()
if firstExists || !secondExists {
t.Fatalf("数据库凭据回收范围错误:first=%v second=%v", firstExists, secondExists)
}
if err := manager.Discard(first.VerificationToken); err == nil {
t.Fatal("数据库验证令牌被重复回收")
}
}
func TestDatabaseVerificationDiscardAllExceptProtectsFormalCredential(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
protected, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "formal_user", Password: "formal-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
pending, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "postgres", Host: "db.example.test", Database: "scert",
Username: "pending_user", Password: "pending-password", TLSMode: "verify-full",
})
if err != nil {
t.Fatal(err)
}
manager.DiscardAllExcept(" " + protected.CredentialRef + " ")
if _, err := manager.Resolve(protected.VerificationToken); err == nil {
t.Fatal("批量清理后受保护凭据对应的验证令牌仍然有效")
}
if _, err := manager.Resolve(pending.VerificationToken); err == nil {
t.Fatal("批量清理后待处理凭据对应的验证令牌仍然有效")
}
store.mu.Lock()
_, protectedExists := store.values[protected.CredentialRef]
_, pendingExists := store.values[pending.CredentialRef]
store.mu.Unlock()
if !protectedExists || pendingExists {
t.Fatalf("数据库凭据保护范围错误:protected=%v pending=%v", protectedExists, pendingExists)
}
}
func TestDatabaseVerificationResolveIsServerOnlyAndDoesNotConsumeToken(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
result, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
resolved, err := manager.Resolve(result.VerificationToken)
if err != nil {
t.Fatalf("按令牌解析数据库验证结果失败:%v", err)
}
if resolved.Config.CredentialRef != result.CredentialRef || resolved.CredentialRef != result.CredentialRef || resolved.ExpiresAt != result.ExpiresAt {
t.Fatalf("解析的数据库验证状态不完整:%+v", resolved)
}
encoded, err := json.Marshal(resolved)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), result.CredentialRef) || strings.Contains(string(encoded), result.Config.DSN) {
t.Fatalf("服务端数据库验证状态可被意外序列化:%s", encoded)
}
if _, err := manager.Consume(result.VerificationToken, result.CredentialRef); err != nil {
t.Fatalf("Resolve 不应消费数据库验证令牌:%v", err)
}
if _, err := manager.Resolve(result.VerificationToken); err == nil {
t.Fatal("已消费的数据库验证令牌仍可被解析")
}
}
func TestDatabaseVerificationConsumesTokenOnlyOnceUnderConcurrency(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
result, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
results := make(chan error, 2)
for range 2 {
go func() {
<-start
_, consumeErr := manager.Consume(result.VerificationToken, result.CredentialRef)
results <- consumeErr
}()
}
close(start)
successes := 0
for range 2 {
if <-results == nil {
successes++
}
}
if successes != 1 {
t.Fatalf("并发消费数据库验证令牌成功次数为 %d,预期为 1", successes)
}
}
func TestDatabaseVerificationPutFailureQueuesCredentialCleanup(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{putFailure: true, deleteFailures: 1}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
_, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "true",
})
if err == nil {
t.Fatal("expected credential persistence failure")
}
store.mu.Lock()
retainedAfterFailure := len(store.values)
store.mu.Unlock()
if retainedAfterFailure != 1 {
t.Fatalf("expected failed persistence cleanup to retain one credential temporarily, got %d", retainedAfterFailure)
}
manager.retryPendingCredentialCleanup()
store.mu.Lock()
remaining := len(store.values)
store.mu.Unlock()
if remaining != 0 {
t.Fatalf("expected pending credential cleanup to succeed on retry, got %d remaining", remaining)
}
}
func TestDatabaseVerificationDiscardFailureRestoresTokenForRetry(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{deleteFailures: 1}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
result, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
if err := manager.Discard(result.VerificationToken); err == nil {
t.Fatal("expected first credential discard to fail")
}
if _, err := manager.Resolve(result.VerificationToken); err != nil {
t.Fatalf("expected failed discard to restore the verification token: %v", err)
}
store.mu.Lock()
_, retained := store.values[result.CredentialRef]
store.mu.Unlock()
if !retained {
t.Fatal("credential must remain available while the token is restored")
}
if err := manager.Discard(result.VerificationToken); err != nil {
t.Fatalf("expected retry discard to succeed: %v", err)
}
if _, err := manager.Resolve(result.VerificationToken); err == nil {
t.Fatal("discarded token must not remain usable")
}
store.mu.Lock()
_, retained = store.values[result.CredentialRef]
store.mu.Unlock()
if retained {
t.Fatal("successfully discarded credential must be deleted")
}
}
func TestDatabaseVerificationExpiredCleanupFailureRestoresTokenForRetry(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{deleteFailures: 1}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
result, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "postgres", Host: "db.example.test", Database: "scert",
Username: "scert_user", Password: "database-password", TLSMode: "verify-full",
})
if err != nil {
t.Fatal(err)
}
manager.now = func() time.Time { return result.ExpiresAt }
if _, err := manager.Check(result.VerificationToken, result.CredentialRef); err != nil {
t.Fatalf("expected failed expiry cleanup to preserve the session for retry: %v", err)
}
store.mu.Lock()
_, retained := store.values[result.CredentialRef]
store.mu.Unlock()
if !retained {
t.Fatal("credential must remain available after failed expiry cleanup")
}
if _, err := manager.Check(result.VerificationToken, result.CredentialRef); err == nil {
t.Fatal("expired token must be invalid after retry cleanup succeeds")
}
store.mu.Lock()
_, retained = store.values[result.CredentialRef]
store.mu.Unlock()
if retained {
t.Fatal("successfully cleaned expired credential must be deleted")
}
}
func TestDatabaseVerificationDiscardAllRestoresOnlyFailedSession(t *testing.T) {
base := config.Default(t.TempDir())
store := &memoryDatabaseStore{deleteFailures: 1}
manager := NewDatabaseVerificationManager(base, store)
manager.verify = func(context.Context, config.DatabaseConfig) error { return nil }
first, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "first", Password: "first-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
second, err := manager.Verify(t.Context(), DatabaseInput{
Driver: "mysql", Host: "db.example.test", Database: "scert",
Username: "second", Password: "second-password", TLSMode: "true",
})
if err != nil {
t.Fatal(err)
}
manager.DiscardAll()
valid := 0
for _, token := range []string{first.VerificationToken, second.VerificationToken} {
if _, err := manager.Resolve(token); err == nil {
valid++
}
}
if valid != 1 {
t.Fatalf("expected exactly one failed batch cleanup to restore its token, got %d", valid)
}
manager.DiscardAll()
store.mu.Lock()
remaining := len(store.values)
store.mu.Unlock()
if remaining != 0 {
t.Fatalf("expected second batch cleanup to remove the restored credential, got %d remaining", remaining)
}
}