333 lines
9.6 KiB
Go
333 lines
9.6 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 postgresql_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/client-go/kubernetes"
|
|
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
|
|
|
secretadapter "git.ddupan.top/panxiao81/ayatori/internal/database/adapter/kubernetes"
|
|
"git.ddupan.top/panxiao81/ayatori/internal/database/adapter/postgresql"
|
|
"git.ddupan.top/panxiao81/ayatori/internal/database/application"
|
|
"git.ddupan.top/panxiao81/ayatori/internal/database/domain/instance"
|
|
)
|
|
|
|
const (
|
|
fixtureHost = "fixture.invalid"
|
|
fixtureUser = "postgres"
|
|
dockerExec = "exec"
|
|
fixtureImage = "postgres@sha256:18cfe3ef5e6815560c98237d6216d1e5119702fb0f3894c8785dd58b8bbe5d73"
|
|
fixturePassword = "AYATORI-TEST-ONLY-initial-password"
|
|
rotatedPassword = "AYATORI-TEST-ONLY-rotated-password"
|
|
controllerNamespace = "database-controller"
|
|
secretName = "management"
|
|
)
|
|
|
|
// fixture 不接受外部 DSN,只创建自己的临时容器并按确切 ID 清理。
|
|
func postgresFixture(t *testing.T, ctx context.Context) (string, int) {
|
|
t.Helper()
|
|
output, err := exec.CommandContext(ctx, "docker", "run", "--rm", "-d", "-p", "127.0.0.1::5432",
|
|
"-e", "POSTGRES_PASSWORD="+fixturePassword, fixtureImage).Output()
|
|
if err != nil {
|
|
t.Fatalf("cannot start isolated PostgreSQL fixture: %s", fixtureCommandError(err))
|
|
}
|
|
id := strings.TrimSpace(string(output))
|
|
if !regexp.MustCompile(`^[a-f0-9]{64}$`).MatchString(id) {
|
|
t.Fatal("unexpected container identifier")
|
|
}
|
|
t.Cleanup(func() {
|
|
cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
if exec.CommandContext(cleanup, "docker", "rm", "-f", id).Run() != nil {
|
|
t.Error("fixture cleanup failed")
|
|
}
|
|
})
|
|
output, err = exec.CommandContext(ctx, "docker", "inspect", "--format", `{{(index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort}}`, id).Output()
|
|
if err != nil {
|
|
t.Fatalf("cannot inspect fixture port: %s", fixtureCommandError(err))
|
|
}
|
|
port, err := strconv.Atoi(strings.TrimSpace(string(output)))
|
|
if err != nil {
|
|
t.Fatal("invalid fixture port")
|
|
}
|
|
// 初次 init 的临时服务器只监听 Unix socket,必须等最终 TCP listener。
|
|
for exec.CommandContext(ctx, "docker", dockerExec, id, "pg_isready", "-h", "127.0.0.1", "-U", fixtureUser).Run() != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("fixture startup timed out")
|
|
case <-time.After(200 * time.Millisecond):
|
|
}
|
|
}
|
|
return id, port
|
|
}
|
|
|
|
// Output 将 stderr 保存在 ExitError 中;保留诊断,但不打印命令参数和测试密码。
|
|
func fixtureCommandError(err error) string {
|
|
detail := err.Error()
|
|
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
|
|
detail += ": " + strings.TrimSpace(string(exitErr.Stderr))
|
|
}
|
|
redactor := strings.NewReplacer(
|
|
fixturePassword, "[REDACTED]",
|
|
rotatedPassword, "[REDACTED]",
|
|
)
|
|
return redactor.Replace(detail)
|
|
}
|
|
|
|
func TestFixtureCommandErrorPreservesDiagnosticsAndRedactsPasswords(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want string
|
|
}{
|
|
{
|
|
name: "missing docker executable",
|
|
err: &exec.Error{Name: "docker", Err: exec.ErrNotFound},
|
|
want: "executable file not found",
|
|
},
|
|
{
|
|
name: "daemon failure from stderr",
|
|
err: &exec.ExitError{Stderr: []byte("Cannot connect to the Docker daemon")},
|
|
want: "Cannot connect to the Docker daemon",
|
|
},
|
|
{
|
|
name: "passwords in stderr",
|
|
err: &exec.ExitError{Stderr: []byte("failure: " + fixturePassword + " " + rotatedPassword)},
|
|
want: "failure: [REDACTED] [REDACTED]",
|
|
},
|
|
{
|
|
name: "password in error text",
|
|
err: errors.New("failure: " + fixturePassword),
|
|
want: "failure: [REDACTED]",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
detail := fixtureCommandError(tt.err)
|
|
if !strings.Contains(detail, tt.want) {
|
|
t.Fatalf("diagnostic lost expected information: %q", tt.want)
|
|
}
|
|
if strings.Contains(detail, fixturePassword) || strings.Contains(detail, rotatedPassword) {
|
|
t.Fatal("diagnostic exposed a fixture password")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func target(t *testing.T, port int, mode instance.TLSMode) instance.ObservationTarget {
|
|
t.Helper()
|
|
id, err := instance.NewIdentity("fixture-uid", "fixture")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
revision, err := instance.NewRevision(1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
endpoint, err := instance.NewEndpoint(instance.EndpointValues{
|
|
Host: fixtureHost,
|
|
HostAddr: "127.0.0.1",
|
|
Port: port,
|
|
ManagementDatabase: fixtureUser,
|
|
TLSMode: mode,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref, err := instance.NewCredentialReference(instance.CredentialReferenceValues{
|
|
Name: secretName,
|
|
UsernameKey: "login",
|
|
PasswordKey: "credential",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
definition, err := instance.NewDefinition(endpoint, ref)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
value, err := instance.NewObservationTarget(id, revision, definition)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return value
|
|
}
|
|
|
|
// 在真实读取前设置屏障,确定性验证观测期间 Secret 变化;实际数据仍来自 API server。
|
|
type gatedReader struct {
|
|
application.CredentialReader
|
|
beforeRead func()
|
|
}
|
|
|
|
func (r *gatedReader) Read(ctx context.Context, ref instance.CredentialReference) (application.Credentials, error) {
|
|
if r.beforeRead != nil {
|
|
r.beforeRead()
|
|
}
|
|
return r.CredentialReader.Read(ctx, ref)
|
|
}
|
|
|
|
// credentialFixture 为每个场景创建独立 API server、PostgreSQL 和应用服务。
|
|
type credentialFixture struct {
|
|
ctx context.Context
|
|
client *kubernetes.Clientset
|
|
reader *secretadapter.SecretCredentials
|
|
deniedReader *secretadapter.SecretCredentials
|
|
gate *gatedReader
|
|
service *application.InstanceService
|
|
target instance.ObservationTarget
|
|
containerID string
|
|
port int
|
|
}
|
|
|
|
func newCredentialFixture(t *testing.T) *credentialFixture {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
t.Cleanup(cancel)
|
|
|
|
environment := &envtest.Environment{}
|
|
config, err := environment.Start()
|
|
if err != nil {
|
|
t.Fatal("envtest startup failed", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := environment.Stop(); err != nil {
|
|
t.Error("envtest cleanup failed", err)
|
|
}
|
|
})
|
|
|
|
client, err := kubernetes.NewForConfig(config)
|
|
if err != nil {
|
|
t.Fatal("cannot create test client")
|
|
}
|
|
for _, namespace := range []string{controllerNamespace, "unrelated"} {
|
|
_, err := client.CoreV1().Namespaces().Create(
|
|
ctx,
|
|
&corev1.Namespace{Name: namespace},
|
|
metav1.CreateOptions{},
|
|
)
|
|
if err != nil {
|
|
t.Fatal("cannot create fixture namespace")
|
|
}
|
|
}
|
|
|
|
reader, err := secretadapter.NewSecretCredentials(config, controllerNamespace)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
user, err := environment.AddUser(envtest.User{Name: "without-secret-access"}, config)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deniedReader, err := secretadapter.NewSecretCredentials(user.Config(), controllerNamespace)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
containerID, port := postgresFixture(t, ctx)
|
|
gate := &gatedReader{CredentialReader: reader}
|
|
service, err := application.NewInstanceService(gate, postgresql.Connector{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(service.Close)
|
|
|
|
return &credentialFixture{
|
|
ctx: ctx,
|
|
client: client,
|
|
reader: reader,
|
|
deniedReader: deniedReader,
|
|
gate: gate,
|
|
service: service,
|
|
target: target(t, port, instance.TLSDisable),
|
|
containerID: containerID,
|
|
port: port,
|
|
}
|
|
}
|
|
|
|
func (f *credentialFixture) createSecret(t *testing.T, namespace string) {
|
|
t.Helper()
|
|
secret := &corev1.Secret{
|
|
Name: secretName,
|
|
Data: map[string][]byte{
|
|
"login": []byte(fixtureUser),
|
|
"credential": []byte(fixturePassword),
|
|
},
|
|
}
|
|
if _, err := f.client.CoreV1().Secrets(namespace).Create(f.ctx, secret, metav1.CreateOptions{}); err != nil {
|
|
t.Fatal("cannot create fixture Secret")
|
|
}
|
|
}
|
|
|
|
func (f *credentialFixture) updateSecret(t *testing.T, change func(*corev1.Secret)) {
|
|
t.Helper()
|
|
secrets := f.client.CoreV1().Secrets(controllerNamespace)
|
|
secret, err := secrets.Get(f.ctx, secretName, metav1.GetOptions{})
|
|
if err != nil {
|
|
t.Fatal("cannot read fixture Secret")
|
|
}
|
|
change(secret)
|
|
if _, err := secrets.Update(f.ctx, secret, metav1.UpdateOptions{}); err != nil {
|
|
t.Fatal("cannot update fixture Secret")
|
|
}
|
|
}
|
|
|
|
func (f *credentialFixture) observeVersion(t *testing.T) {
|
|
t.Helper()
|
|
version, err := f.service.ObserveVersion(f.ctx, f.target)
|
|
if err != nil {
|
|
t.Fatal("version observation failed", err)
|
|
}
|
|
if version == "" {
|
|
t.Fatal("successful observation returned an empty version")
|
|
}
|
|
}
|
|
|
|
func (f *credentialFixture) queryPostgres(t *testing.T, sql string) string {
|
|
t.Helper()
|
|
output, err := exec.CommandContext(
|
|
f.ctx, "docker", dockerExec, f.containerID,
|
|
"psql", "-U", fixtureUser, "-tAc", sql,
|
|
).Output()
|
|
if err != nil {
|
|
t.Fatal("fixture SQL failed")
|
|
}
|
|
return strings.TrimSpace(string(output))
|
|
}
|
|
|
|
func (f *credentialFixture) backendIDs(t *testing.T) string {
|
|
t.Helper()
|
|
return f.queryPostgres(t, `
|
|
SELECT pid
|
|
FROM pg_stat_activity
|
|
WHERE application_name = 'ayatori-database-management'
|
|
ORDER BY pid
|
|
`)
|
|
}
|