Files
ayatori/internal/database/adapter/openbao/preparation_integration_test.go
T
panxiao81 7834cab97f
Verify / test (pull_request) Successful in 13m29s
Verify / lint (pull_request) Successful in 14m13s
Verify / database-integration (pull_request) Successful in 15m48s
refactor: 集中显式注入并归位凭据领域规则
2026-09-27 19:16:48 +00:00

239 lines
8.9 KiB
Go

//go:build integration
package openbao_test
import (
"context"
"errors"
"maps"
"testing"
credentialdomain "git.ddupan.top/panxiao81/ayatori/internal/database/domain/credential"
bao "github.com/openbao/openbao/api/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
databasev1alpha1 "git.ddupan.top/panxiao81/ayatori/api/database/v1alpha1"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/binding"
)
func TestCredentialPreparationWithRealBackends(t *testing.T) {
fixture := newPreparationFixture(t)
t.Run("创建重启和幂等", func(t *testing.T) { testPreparationRestart(t, fixture) })
t.Run("已有值不认领", func(t *testing.T) { testPreparationExisting(t, fixture) })
t.Run("确认写入失败", func(t *testing.T) { testPreparationLostConfirmation(t, fixture) })
t.Run("后端结果不确定", func(t *testing.T) { testPreparationUncertain(t, fixture) })
t.Run("创建中绑定变化", func(t *testing.T) { testPreparationChangedBinding(t, fixture) })
t.Run("依赖恢复与固定位置", func(t *testing.T) { testPreparationDependencies(t, fixture) })
t.Run("并发用例只有一个写入", func(t *testing.T) { testPreparationConcurrency(t, fixture) })
t.Run("位置被API裁剪时拒绝外部写入", func(t *testing.T) { testPreparationPruning(t, fixture) })
t.Run("实际manager的watch与重启", func(t *testing.T) { testPreparationWatch(t, fixture) })
}
func testPreparationRestart(t *testing.T, f *preparationFixture) {
database, tenant := f.bound(t, "restart")
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, "CredentialPrepared")
path := database.Status.CredentialRef.Path
before, err := f.bao.KVv2("secret").Get(t.Context(), path)
if err != nil {
t.Fatal("无法读取隔离测试凭据")
}
if before.Data["username"] != "restart" || before.Data["database"] != "restart" || len(before.Data) != 7 {
t.Fatal("用例未使用绑定目标生成七键凭据")
}
// 两次新用例实例模拟重启,第二轮必须没有无意义状态更新。
for range 2 {
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
}
f.status(t, database, 1, "CredentialPrepared")
revision := database.ResourceVersion
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, "CredentialPrepared")
if database.ResourceVersion != revision {
t.Fatal("幂等重试不应改写 status")
}
after, err := f.bao.KVv2("secret").Get(t.Context(), path)
if err != nil || after.VersionMetadata.Version != 1 || !maps.Equal(before.Data, after.Data) {
t.Fatal("重启后不应生成或覆盖凭据")
}
if err := f.api.Delete(t.Context(), tenant); err != nil {
t.Fatal(err)
}
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, "PreparationStopped")
if err := f.api.Delete(t.Context(), database); err != nil {
t.Fatal(err)
}
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, "PreparationStopped")
if len(database.Finalizers) == 0 {
t.Fatal("本切片不得解除删除保护")
}
if _, err := f.bao.KVv2("secret").Get(t.Context(), path); err != nil {
t.Fatal("删除流程不得在本切片清理凭据")
}
}
func testPreparationExisting(t *testing.T, f *preparationFixture) {
database, _ := f.bound(t, "existing")
store := fixtureStore(t, f.bao)
path, err := store.ProvisionPath(string(database.UID))
if err != nil {
t.Fatal(err)
}
if err := store.Create(t.Context(), path, fixtureCredential(t)); err != nil {
t.Fatal(err)
}
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 0, binding.Conflict)
// 即使管理员删除了后端值,未解除的不确定状态也不能自动创建。
if err := f.bao.KVv2("secret").DeleteMetadata(t.Context(), path); err != nil {
t.Fatal("无法清理隔离测试 key")
}
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
if _, err := f.bao.KVv2("secret").Get(t.Context(), path); !errors.Is(err, bao.ErrSecretNotFound) {
t.Fatal("Conflict 重入不应生成替代凭据")
}
}
// 故障只注入确认保存边界;此前写入使用真实 API server 和 Bao。
type failedConfirmationClient struct{ client.Client }
func (c failedConfirmationClient) Status() client.SubResourceWriter {
return failedConfirmationWriter{SubResourceWriter: c.Client.Status()}
}
type failedConfirmationWriter struct{ client.SubResourceWriter }
func (w failedConfirmationWriter) Update(ctx context.Context, object client.Object, options ...client.SubResourceUpdateOption) error {
if database, ok := object.(*databasev1alpha1.PostgreSQLDatabase); ok && database.Status.CredentialVersion > 0 {
return errors.New("fixture refuses confirmation update")
}
return w.SubResourceWriter.Update(ctx, object, options...)
}
func testPreparationLostConfirmation(t *testing.T, f *preparationFixture) {
database, _ := f.bound(t, "confirmation")
service := f.service(t)
service.Resources = &kubernetes.CredentialResources{Client: failedConfirmationClient{f.api}, Reader: f.api}
if err := service.Reconcile(t.Context(), database.Name); err == nil {
t.Fatal("确认写入失败应返回 API 错误")
}
f.status(t, database, 0, "CreationStarted")
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 0, binding.Conflict)
stored, err := f.bao.KVv2("secret").Get(t.Context(), database.Status.CredentialRef.Path)
if err != nil || stored.VersionMetadata.Version != 1 {
t.Fatal("写入结果必须保留且不能被重启认领")
}
}
type afterCreateStore struct {
application.CredentialStore
after func() error
}
func (s afterCreateStore) CreateCredential(ctx context.Context, location credentialdomain.Location, credential credentialdomain.ApplicationCredential) (int64, error) {
version, err := s.CredentialStore.CreateCredential(ctx, location, credential)
if err != nil {
return 0, err
}
if err := s.after(); err != nil {
return 0, err
}
return version, nil
}
func testPreparationUncertain(t *testing.T, f *preparationFixture) {
database, _ := f.bound(t, "uncertain")
service := f.service(t)
service.Store = afterCreateStore{CredentialStore: service.Store, after: func() error { return application.ErrCredentialUncertain }}
if err := service.Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 0, binding.Conflict)
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 0, binding.Conflict)
}
func testPreparationChangedBinding(t *testing.T, f *preparationFixture) {
database, tenant := f.bound(t, "changed")
service := f.service(t)
service.Store = afterCreateStore{CredentialStore: service.Store, after: func() error {
return f.api.Delete(t.Context(), tenant)
}}
if err := service.Reconcile(t.Context(), database.Name); err == nil {
t.Fatal("中途删除 Tenant 后不得确认凭据")
}
f.status(t, database, 0, "CreationStarted")
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 0, binding.Conflict)
}
func testPreparationDependencies(t *testing.T, f *preparationFixture) {
database, _ := f.bound(t, "dependency")
denied := fixtureClient(t, f.bao.Address())
denied.ClearToken()
service := f.service(t)
service.Store = fixtureStore(t, denied)
if err := service.Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 0, binding.DependencyUnavailable)
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, "CredentialPrepared")
if err := service.Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, binding.DependencyUnavailable)
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, "CredentialPrepared")
moved, err := openbao.NewCredentials(f.bao, "other", "elsewhere")
if err != nil {
t.Fatal(err)
}
service.Store = moved
if err := service.Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, binding.DependencyUnavailable)
if database.Status.CredentialRef.Mount != "secret" {
t.Fatal("配置变化不得迁移固定位置")
}
if err := f.bao.KVv2("secret").Delete(t.Context(), database.Status.CredentialRef.Path); err != nil {
t.Fatal("无法准备测试软删除")
}
if err := f.service(t).Reconcile(t.Context(), database.Name); err != nil {
t.Fatal(err)
}
f.status(t, database, 1, binding.Conflict)
}