feat: 固定 Database 凭据位置与确认版本
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
//go:build integration
|
||||
|
||||
package openbao_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
bao "github.com/openbao/openbao/api/v2"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||
)
|
||||
|
||||
func TestConfirmedCredentialRecoveryWithRealOpenBao(t *testing.T) {
|
||||
root := baoFixture(t)
|
||||
store := fixtureStore(t, root)
|
||||
credential := fixtureCredential(t)
|
||||
if err := store.Create(t.Context(), credentialPath, credential); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 新 adapter 模拟进程重启:只有已持久化的版本才能恢复读取。
|
||||
restarted := fixtureStore(t, root)
|
||||
if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 0); err != openbao.ErrConflict {
|
||||
t.Fatal("已有值不能替代缺失的确认记录")
|
||||
}
|
||||
observed, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1)
|
||||
if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) {
|
||||
t.Fatal("确认版本的凭据不能恢复读取")
|
||||
}
|
||||
// 即使内容相同,新增版本也不属于已确认写入;历史版本仍在不能掩盖改写。
|
||||
if _, err := root.KVv2("secret").Put(t.Context(), credentialPath, credential.SecretData(), bao.WithCheckAndSet(1)); err != nil {
|
||||
t.Fatal("无法准备测试中的版本变化")
|
||||
}
|
||||
if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1); err != openbao.ErrConflict {
|
||||
t.Fatal("最新版本漂移必须报冲突")
|
||||
}
|
||||
if err := root.KVv2("secret").Delete(t.Context(), credentialPath); err != nil {
|
||||
t.Fatal("无法准备测试中的软删除")
|
||||
}
|
||||
if _, err := restarted.ReadConfirmed(t.Context(), credentialPath, 1); err != openbao.ErrConflict {
|
||||
t.Fatal("已确认凭据被删除必须报冲突")
|
||||
}
|
||||
metadata, err := root.KVv2("secret").GetMetadata(t.Context(), credentialPath)
|
||||
if err != nil || metadata.CurrentVersion != 2 {
|
||||
t.Fatal("恢复检查不得生成或覆盖凭据")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package openbao_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
|
||||
)
|
||||
|
||||
func credentialVersionMetadata(version int) map[string]any {
|
||||
return map[string]any{kvVersionKey: version}
|
||||
}
|
||||
|
||||
func TestConfirmedCredentialRead(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
version int64
|
||||
status int
|
||||
meta map[string]any
|
||||
invalid bool
|
||||
want error
|
||||
}{
|
||||
{name: "已确认", version: 1, status: 200, meta: credentialVersionMetadata(1)},
|
||||
{name: "未确认禁止读取", version: 0, want: openbao.ErrConflict},
|
||||
{name: "版本已变化", version: 1, status: 200, meta: credentialVersionMetadata(2), want: openbao.ErrConflict},
|
||||
{name: "版本为零", version: 1, status: 200, meta: credentialVersionMetadata(0), want: openbao.ErrConflict},
|
||||
{name: "响应无法解析", version: 1, status: 200, want: openbao.ErrUnavailable},
|
||||
{name: "内容损坏", version: 1, status: 200, meta: credentialVersionMetadata(1), invalid: true, want: openbao.ErrConflict},
|
||||
{name: "凭据丢失", version: 1, status: 404, want: openbao.ErrConflict},
|
||||
{name: "读取被拒绝", version: 1, status: 403, want: openbao.ErrUnavailable},
|
||||
{name: "后端不可用", version: 1, status: 503, want: openbao.ErrUnavailable},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
credential := fixtureCredential(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if test.version == 0 {
|
||||
t.Error("没有持久化确认记录不能读取已有值")
|
||||
}
|
||||
if r.Method != http.MethodGet || r.URL.RawQuery != "" {
|
||||
t.Error("只允许读取最新值,不能写入或回退历史版本")
|
||||
}
|
||||
w.WriteHeader(test.status)
|
||||
if test.status != http.StatusOK {
|
||||
return
|
||||
}
|
||||
data := credential.SecretData()
|
||||
if test.invalid {
|
||||
delete(data, "password")
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{
|
||||
kvDataKey: map[string]any{kvDataKey: data, "metadata": test.meta},
|
||||
}); err != nil {
|
||||
t.Error("测试响应编码失败")
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
store := fixtureStore(t, fixtureClient(t, server.URL))
|
||||
observed, err := store.ReadConfirmed(t.Context(), credentialPath, test.version)
|
||||
if err != test.want {
|
||||
t.Fatalf("期望 %v,得到 %v", test.want, err)
|
||||
}
|
||||
if err != nil && observed.Validate() == nil {
|
||||
t.Fatal("失败时不能返回可用凭据")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,20 +80,51 @@ func (c *Credentials) accepts(path string) bool {
|
||||
|
||||
// Read 只读取调用方已确认关联的路径;成功读取不构成对既有凭据的自动认领。
|
||||
func (c *Credentials) Read(ctx context.Context, path string) (application.ApplicationCredential, error) {
|
||||
secret, err := c.read(ctx, path)
|
||||
if err != nil {
|
||||
return application.ApplicationCredential{}, err
|
||||
}
|
||||
return application.ParseApplicationCredential(secret.Data)
|
||||
}
|
||||
|
||||
// ReadConfirmed 读取最新值并核对已持久化的确认版本,不回退读取历史版本。
|
||||
// 确认后的删除或改写需要人工处理,不能因此重新生成密码。
|
||||
func (c *Credentials) ReadConfirmed(ctx context.Context, path string, version int64) (application.ApplicationCredential, error) {
|
||||
if version < 1 {
|
||||
return application.ApplicationCredential{}, ErrConflict
|
||||
}
|
||||
secret, err := c.read(ctx, path)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return application.ApplicationCredential{}, ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return application.ApplicationCredential{}, err
|
||||
}
|
||||
if secret.VersionMetadata == nil || int64(secret.VersionMetadata.Version) != version {
|
||||
return application.ApplicationCredential{}, ErrConflict
|
||||
}
|
||||
credential, err := application.ParseApplicationCredential(secret.Data)
|
||||
if err != nil {
|
||||
return application.ApplicationCredential{}, ErrConflict
|
||||
}
|
||||
return credential, nil
|
||||
}
|
||||
|
||||
func (c *Credentials) read(ctx context.Context, path string) (*bao.KVSecret, error) {
|
||||
if !c.accepts(path) {
|
||||
return application.ApplicationCredential{}, ErrInvalidLocation
|
||||
return nil, ErrInvalidLocation
|
||||
}
|
||||
secret, err := c.kv.Get(ctx, path)
|
||||
if errors.Is(err, bao.ErrSecretNotFound) {
|
||||
return application.ApplicationCredential{}, ErrNotFound
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return application.ApplicationCredential{}, ErrUnavailable
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
if secret == nil || secret.Data == nil {
|
||||
return application.ApplicationCredential{}, ErrNotFound
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return application.ParseApplicationCredential(secret.Data)
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// Create 只创建从未存在过的路径,并验证回读七键与提交值完全一致。
|
||||
|
||||
@@ -36,6 +36,7 @@ const (
|
||||
fixturePassword = "AYATORI-TEST-ONLY-application-password"
|
||||
fixtureToken = "AYATORI-TEST-ONLY-bao-token"
|
||||
kvDataKey = "data"
|
||||
kvVersionKey = "version"
|
||||
)
|
||||
|
||||
func fixtureCredential(t *testing.T) application.ApplicationCredential {
|
||||
@@ -66,7 +67,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
|
||||
if json.NewDecoder(r.Body).Decode(&request) != nil || request.Options.CAS == nil || *request.Options.CAS != 0 {
|
||||
t.Error("create request must explicitly require CAS=0")
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{"version": 1}}); err != nil {
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: map[string]any{kvVersionKey: 1}}); err != nil {
|
||||
t.Error("cannot encode fixture write response")
|
||||
}
|
||||
return
|
||||
@@ -85,7 +86,7 @@ func TestCredentialReadbackMustConfirmTheWrite(t *testing.T) {
|
||||
}
|
||||
response := map[string]any{kvDataKey: data}
|
||||
if scenario != "missing metadata" {
|
||||
response["metadata"] = map[string]any{"version": version}
|
||||
response["metadata"] = map[string]any{kvVersionKey: version}
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(map[string]any{kvDataKey: response}); err != nil {
|
||||
t.Error("cannot encode fixture read response")
|
||||
|
||||
Reference in New Issue
Block a user