Files
ayatori/internal/database/adapter/openbao/credentials_integration_test.go
T
panxiao81 2a4f622f44
Verify / test (pull_request) Successful in 5m24s
Verify / lint (pull_request) Successful in 11m53s
Verify / database-integration (pull_request) Successful in 13m32s
test: 分离 OpenBao 镜像拉取与启动超时并保留诊断
2026-09-25 16:30:09 +00:00

228 lines
8.1 KiB
Go

//go:build integration
/*
Copyright 2026.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package openbao_test
import (
"context"
"errors"
"maps"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os/exec"
"regexp"
"strings"
"sync"
"testing"
"time"
bao "github.com/openbao/openbao/api/v2"
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/openbao"
)
// 只连接本测试创建的无持久卷 dev server,不接受生产地址或环境 token。
func baoFixture(t *testing.T) *bao.Client {
t.Helper()
const image = "openbao/openbao@sha256:5b2486ab0fb90bbc788cc345b0a08616dfb375873ee8be5df3a2fd4d378a67e0"
prepareBaoImage(t, image)
// 冷缓存拉取不占用容器启动和健康检查的一分钟预算。
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
output, err := exec.CommandContext(ctx, "docker", "run", "--pull=never", "--rm", "-d", "-p", "127.0.0.1::8200",
image, "server", "-dev", "-dev-root-token-id="+fixtureToken, "-dev-listen-address=0.0.0.0:8200").Output()
if err != nil {
t.Fatalf("cannot start isolated OpenBao fixture: %s", baoCommandError(ctx, err))
}
id := strings.TrimSpace(string(output))
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
t.Fatal("unexpected fixture container ID")
}
t.Cleanup(func() {
cleanup, stop := context.WithTimeout(context.Background(), 30*time.Second)
defer stop()
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
t.Error("OpenBao fixture cleanup failed")
}
})
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format",
`{{(index (index .NetworkSettings.Ports "8200/tcp") 0).HostPort}}`, id).Output()
if err != nil {
t.Fatalf("cannot inspect fixture port: %s", baoCommandError(ctx, err))
}
client := fixtureClient(t, "http://127.0.0.1:"+strings.TrimSpace(string(output)))
client.SetMaxRetries(0)
for {
if _, err := client.Sys().HealthWithContext(ctx); err == nil {
return client
}
select {
case <-ctx.Done():
t.Fatal("OpenBao fixture startup timed out")
case <-time.After(100 * time.Millisecond):
}
}
}
func prepareBaoImage(t *testing.T, image string) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute)
defer cancel()
if exec.CommandContext(ctx, "docker", "image", "inspect", image).Run() == nil {
return
}
t.Log("pulling isolated OpenBao fixture image (timeout: 5m)")
if _, err := exec.CommandContext(ctx, "docker", "pull", image).Output(); err != nil {
t.Fatalf("cannot pull OpenBao fixture image: %s", baoCommandError(ctx, err))
}
}
// 保留 Docker stderr 与超时原因,但不泄露测试 token/password 或完整命令参数。
func baoCommandError(ctx context.Context, err error) string {
detail := err.Error()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
detail += ": " + strings.TrimSpace(string(exitErr.Stderr))
}
if ctx.Err() != nil {
detail += ": " + ctx.Err().Error()
}
return strings.NewReplacer(fixtureToken, "[REDACTED]", fixturePassword, "[REDACTED]").Replace(detail)
}
func TestBaoCommandError(t *testing.T) {
err := &exec.ExitError{Stderr: []byte("registry unavailable " + fixtureToken + " " + fixturePassword)}
ctx, cancel := context.WithCancel(t.Context())
cancel()
detail := baoCommandError(ctx, err)
if !strings.Contains(detail, "registry unavailable") || !strings.Contains(detail, "context canceled") {
t.Fatal("Docker diagnostic or context failure was lost")
}
if strings.Contains(detail, fixtureToken) || strings.Contains(detail, fixturePassword) {
t.Fatal("Docker diagnostic exposed fixture credentials")
}
}
func TestCredentialConcurrentCreateWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
store := fixtureStore(t, root)
credential := fixtureCredential(t)
results := make(chan error, 2)
var workers sync.WaitGroup
for range 2 {
workers.Go(func() { results <- store.Create(t.Context(), credentialPath, credential) })
}
workers.Wait()
close(results)
succeeded, conflicted := 0, 0
for err := range results {
switch err {
case nil:
succeeded++
case openbao.ErrConflict:
conflicted++
default:
t.Fatal("unexpected concurrent create result")
}
}
if succeeded != 1 || conflicted != 1 {
t.Fatal("CAS must allow exactly one creator")
}
}
func TestCredentialLostWriteResponseWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
address, err := url.Parse(root.Address())
if err != nil {
t.Fatal("invalid fixture address")
}
proxy := httputil.NewSingleHostReverseProxy(address)
proxy.ModifyResponse = func(response *http.Response) error {
if response.Request.Method == http.MethodPut && response.StatusCode == http.StatusOK {
return errors.New("fixture drops successful write response")
}
return nil
}
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, _ error) {
w.WriteHeader(http.StatusBadGateway)
}
server := httptest.NewServer(proxy)
defer server.Close()
store := fixtureStore(t, fixtureClient(t, server.URL))
credential := fixtureCredential(t)
if err := store.Create(t.Context(), credentialPath, credential); err != openbao.ErrUncertain {
t.Fatal("lost response must stop provisioning")
}
confirmed, err := root.KVv2("secret").Get(t.Context(), credentialPath)
if err != nil || !maps.Equal(confirmed.Data, credential.SecretData()) || confirmed.VersionMetadata.Version != 1 {
t.Fatal("fault injection did not preserve the original write")
}
if err := fixtureStore(t, root).Create(t.Context(), credentialPath, credential); err != openbao.ErrConflict {
t.Fatal("restart must not adopt an unconfirmed write")
}
}
func TestCredentialsWithRealOpenBao(t *testing.T) {
root := baoFixture(t)
ctx := t.Context()
// root 仅用于 fixture 装配;实际读写使用固定前缀的短期 token。
policy := `path "secret/data/applications/*" { capabilities = ["create", "update", "read"] }`
if err := root.Sys().PutPolicyWithContext(ctx, "application-fixture", policy); err != nil {
t.Fatal("cannot configure fixture policy")
}
secret, err := root.Auth().Token().CreateWithContext(ctx, &bao.TokenCreateRequest{
Policies: []string{"application-fixture"}, NoDefaultPolicy: true, TTL: "5m",
})
if err != nil {
t.Fatal("cannot create scoped fixture token")
}
client := fixtureClient(t, root.Address())
client.SetToken(secret.Auth.ClientToken)
store := fixtureStore(t, client)
credential := fixtureCredential(t)
if err := store.Create(ctx, credentialPath, credential); err != nil {
t.Fatal(err)
}
// 重建适配器读取已确认路径;重复 Create 仍报冲突,不把读取当作认领。
restarted := fixtureStore(t, client)
observed, err := restarted.Read(ctx, credentialPath)
if err != nil || !maps.Equal(observed.SecretData(), credential.SecretData()) {
t.Fatal("confirmed credential was not preserved across adapter restart")
}
if err := restarted.Create(ctx, credentialPath, credential); !errors.Is(err, openbao.ErrConflict) {
t.Fatal("existing credential must conflict even if contents match")
}
metadata, err := root.KVv2("secret").GetMetadata(ctx, credentialPath)
if err != nil || metadata.CurrentVersion != 1 {
t.Fatal("duplicate create changed credential version")
}
if _, err := client.KVv2("secret").Get(ctx, "management/instance"); err == nil {
t.Fatal("scoped token accessed management credentials")
}
if err := root.KVv2("secret").Delete(ctx, credentialPath); err != nil {
t.Fatal("cannot soft-delete fixture credential")
}
if _, err := store.Read(ctx, credentialPath); err != openbao.ErrNotFound {
t.Fatal("soft-deleted credential must not be usable")
}
if err := store.Create(ctx, credentialPath, credential); err != openbao.ErrConflict {
t.Fatal("soft-deleted credential must not be recreated")
}
}