169 lines
6.3 KiB
Go
169 lines
6.3 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
|
)
|
|
|
|
func TestPostgresToolConnectionKeepsSecretsOutOfArguments(t *testing.T) {
|
|
t.Setenv("PGHOST", "unauthorized.example.test")
|
|
t.Setenv("PGSERVICE", "unexpected-service")
|
|
t.Setenv("PGOPTIONS", "-c search_path=unexpected")
|
|
t.Setenv("PGSSLMODE", "disable")
|
|
t.Setenv("PGPASSWORD", "unexpected-password")
|
|
const password = "p@ss:' /\\\nword"
|
|
parsed, _ := url.Parse("postgres://db.example.test:5432/scert?sslmode=verify-full&application_name=scert")
|
|
parsed.User = url.UserPassword("backup-user", password)
|
|
dsn, env, err := postgresToolConnection(config.DatabaseConfig{Driver: "postgres", DSN: parsed.String()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(dsn, password) || strings.Contains(dsn, "backup-user") {
|
|
t.Fatal("账号密码不能进入命令行连接参数")
|
|
}
|
|
target, err := url.Parse(dsn)
|
|
if err != nil || target.Host != "db.example.test:5432" || target.Path != "/scert" || target.User != nil {
|
|
t.Fatal("维护连接改变了数据库目标")
|
|
}
|
|
for key, value := range map[string]string{"sslmode": "verify-full", "sslrootcert": "system", "connect_timeout": "10", "gssencmode": "disable"} {
|
|
if target.Query().Get(key) != value {
|
|
t.Fatalf("维护连接参数未保留安全值:%s", key)
|
|
}
|
|
}
|
|
for _, entry := range env {
|
|
if strings.HasPrefix(entry, "PGHOST=") || strings.HasPrefix(entry, "PGSERVICE=") || strings.HasPrefix(entry, "PGOPTIONS=") || strings.Contains(entry, "unexpected-password") {
|
|
t.Fatal("工具不应继承外部 PostgreSQL 连接环境")
|
|
}
|
|
}
|
|
if !slices.Contains(env, "PGUSER=backup-user") || !slices.Contains(env, "PGPASSWORD="+password) || !slices.Contains(env, "PGPASSFILE="+os.DevNull) {
|
|
t.Fatal("子进程专属凭据环境不完整")
|
|
}
|
|
}
|
|
|
|
func TestPostgresToolConnectionRejectsAmbiguousParameters(t *testing.T) {
|
|
for _, raw := range []string{
|
|
"postgres://owner:password@db.test/scert?sslmode=disable",
|
|
"postgres://owner:password@db.test/scert?sslmode=verify-full&password=override",
|
|
"postgres://owner:password@db.test/scert?sslmode=verify-full&host=other.test",
|
|
"postgres://owner:password@db.test/scert?sslmode=verify-full&options=-c+search_path%3Dother",
|
|
"postgres://owner:password@db.test/scert?sslmode=verify-full&service=other",
|
|
"postgres://owner:p%00word@db.test/scert?sslmode=verify-full",
|
|
"postgres://db.test/scert?sslmode=verify-full",
|
|
} {
|
|
if _, _, err := postgresToolConnection(config.DatabaseConfig{Driver: "postgres", DSN: raw}); err == nil {
|
|
t.Fatal("不安全或语义不一致的维护连接未被拒绝")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostgresToolConnectionPreservesExplicitTLSFiles(t *testing.T) {
|
|
parsed, _ := url.Parse("postgres://owner:password@db.test/scert?sslmode=verify-full")
|
|
query := parsed.Query()
|
|
for key, value := range map[string]string{"sslrootcert": "/secure/ca bundle.pem", "sslcert": "/secure/client.pem", "sslkey": "/secure/client.key", "connect_timeout": "7"} {
|
|
query.Set(key, value)
|
|
}
|
|
parsed.RawQuery = query.Encode()
|
|
dsn, _, err := postgresToolConnection(config.DatabaseConfig{Driver: "postgres", DSN: parsed.String()})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
actual, _ := url.Parse(dsn)
|
|
for key, value := range query {
|
|
if actual.Query().Get(key) != value[0] {
|
|
t.Fatalf("维护连接改变了显式 TLS 或超时配置:%s", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPostgresToolOutputLimitAppliesToCopy(t *testing.T) {
|
|
output := &limitedPostgresOutput{remaining: 4}
|
|
if _, err := io.Copy(output, strings.NewReader("output-over-limit")); err == nil {
|
|
t.Fatal("工具输出不能通过流式复制绕过容量限制")
|
|
}
|
|
if len(output.String()) > 4 {
|
|
t.Fatal("超限输出不应保留在内存中")
|
|
}
|
|
}
|
|
|
|
func TestPostgresToolsRequireCompatibleVersions(t *testing.T) {
|
|
for _, pair := range [][2]string{{"16.4", "16.9"}, {"15.9", "15.9"}, {"16.4", "17.2"}, {"invalid", "16.4"}} {
|
|
t.Run(strings.Join(pair[:], "-"), func(t *testing.T) {
|
|
tools := &postgresTools{dumpPath: "pg_dump", restorePath: "pg_restore"}
|
|
tools.run = func(ctx context.Context, path string, args, env []string, output io.Writer) error {
|
|
if ctx.Err() != nil || !slices.Equal(args, []string{"--version"}) {
|
|
t.Fatal("版本检查参数无效")
|
|
}
|
|
version := pair[0]
|
|
if path == "pg_restore" {
|
|
version = pair[1]
|
|
}
|
|
_, err := io.WriteString(output, path+" (PostgreSQL) "+version+"\n")
|
|
return err
|
|
}
|
|
err := tools.validateVersions(t.Context())
|
|
if (err == nil) != (pair[0] == "16.4" && pair[1] == "16.9") {
|
|
t.Fatalf("版本检查结果不符:%v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPostgresMissingToolsFailBeforeBackupFilesystemChanges(t *testing.T) {
|
|
t.Setenv("PATH", t.TempDir())
|
|
if _, err := loadPostgresTools(t.Context()); err == nil {
|
|
t.Fatal("没有官方工具时必须明确拒绝")
|
|
}
|
|
root := t.TempDir()
|
|
output := filepath.Join(root, "missing", "backup")
|
|
err := createBackup(t.Context(), config.Config{Database: config.DatabaseConfig{Driver: "postgres"}}, "unused.json", output)
|
|
if err == nil {
|
|
t.Fatal("缺少工具时不能生成备份")
|
|
}
|
|
if _, err := os.Stat(filepath.Dir(output)); !os.IsNotExist(err) {
|
|
t.Fatal("工具检查失败前不应创建输出目录")
|
|
}
|
|
}
|
|
|
|
func TestPostgresToolHelperProcess(t *testing.T) {
|
|
mode := os.Getenv("SCERT_POSTGRES_TOOL_TEST")
|
|
if mode == "" {
|
|
return
|
|
}
|
|
if mode == "cancel" {
|
|
time.Sleep(30 * time.Second)
|
|
} else {
|
|
_, _ = io.WriteString(os.Stderr, "sensitive-database-value")
|
|
os.Exit(7)
|
|
}
|
|
}
|
|
|
|
func TestPostgresToolExecutionRedactsErrorsAndCancels(t *testing.T) {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, mode := range []string{"failure", "cancel"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
|
|
defer cancel()
|
|
err := executePostgresTool(ctx, executable, []string{"-test.run=^TestPostgresToolHelperProcess$"}, append(postgresToolEnvironment(), "SCERT_POSTGRES_TOOL_TEST="+mode), io.Discard)
|
|
if err == nil || strings.Contains(err.Error(), "sensitive-database-value") || strings.Contains(err.Error(), executable) {
|
|
t.Fatal("工具错误必须脱敏并保持失败状态")
|
|
}
|
|
if mode == "cancel" && !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatal("超时必须终止工具并返回取消原因")
|
|
}
|
|
})
|
|
}
|
|
}
|