Smail/backend/services/setup/db_database_test.go
smail 3ba7252948
Some checks are pending
Security Checks / security (push) Waiting to run
实现全新部署数据清理保护
部署脚本、外部数据库初始化保护及统一证书域名配置已完成;不保留旧运行数据。
2026-10-01 13:13:12 +08:00

434 lines
12 KiB
Go

package setup
import (
"context"
"database/sql"
stderrors "errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration"
"github.com/go-sql-driver/mysql"
"github.com/lib/pq"
)
func TestMarkSetupInProgressClearsLegacyInitializationFlag(t *testing.T) {
cfg := &config.Config{IsInit: true}
markSetupInProgress(cfg)
if cfg.IsInit {
t.Fatal("legacy initialization flag must be cleared during setup")
}
if cfg.SetupState != config.SetupStateBootstrap {
t.Fatalf("setup state = %q, want %q", cfg.SetupState, config.SetupStateBootstrap)
}
}
func TestMarkSetupInProgressPreservesExplicitSetupState(t *testing.T) {
cfg := &config.Config{IsInit: true, SetupState: config.SetupStateNew}
markSetupInProgress(cfg)
if cfg.IsInit {
t.Fatal("legacy initialization flag must be cleared during setup")
}
if cfg.SetupState != config.SetupStateNew {
t.Fatalf("setup state = %q, want %q", cfg.SetupState, config.SetupStateNew)
}
}
type fakeSetupDB struct {
pingErrors []error
pingCalls int
execQueries []string
execError error
tableCount int
tableCountErr error
tableQueries []string
closed bool
}
func (database *fakeSetupDB) PingContext(context.Context) error {
index := database.pingCalls
database.pingCalls++
if index < len(database.pingErrors) {
return database.pingErrors[index]
}
return nil
}
func (database *fakeSetupDB) ExecContext(_ context.Context, query string, _ ...any) (sql.Result, error) {
database.execQueries = append(database.execQueries, query)
return fakeSetupResult(0), database.execError
}
func (database *fakeSetupDB) TableCountContext(_ context.Context, query string) (int, error) {
if database.tableCountErr != nil {
return 0, database.tableCountErr
}
database.tableQueries = append(database.tableQueries, query)
return database.tableCount, nil
}
func (database *fakeSetupDB) Close() error {
database.closed = true
return nil
}
type fakeSetupResult int64
func (result fakeSetupResult) LastInsertId() (int64, error) {
return int64(result), nil
}
func (result fakeSetupResult) RowsAffected() (int64, error) {
return int64(result), nil
}
func TestBuildMySQLDSNUsesStructuredFields(t *testing.T) {
dsn, err := buildMySQLDSN(DatabaseSettings{
User: "root",
Host: "[2001:db8::1]",
Port: "03306",
Password: "p@ss:/?#[]",
Name: "smail_1",
}, "")
if err != nil {
t.Fatal(err)
}
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if parsed.User != "root" || parsed.Passwd != "p@ss:/?#[]" {
t.Fatalf("unexpected credentials in DSN: user=%q password=%q", parsed.User, parsed.Passwd)
}
if parsed.Addr != "[2001:db8::1]:3306" {
t.Fatalf("unexpected address: %q", parsed.Addr)
}
if parsed.DBName != "smail_1" || !parsed.ParseTime {
t.Fatalf("unexpected database options: name=%q parseTime=%v", parsed.DBName, parsed.ParseTime)
}
if parsed.Loc != time.UTC {
t.Fatalf("unexpected database location: %v", parsed.Loc)
}
if !strings.Contains(dsn, "charset=utf8mb4") {
t.Fatalf("charset option is missing from DSN: %q", dsn)
}
}
func TestBuildPostgresDSNUsesStructuredFields(t *testing.T) {
dsn, err := buildPostgresDSN(DatabaseSettings{
User: "smail",
Host: "[2001:db8::2]",
Port: "05432",
Password: "p@ss:/?#[]",
Name: "smail_1",
}, "")
if err != nil {
t.Fatal(err)
}
parsed, ok := parsePostgresURL(dsn)
if !ok {
t.Fatalf("invalid PostgreSQL DSN: %q", dsn)
}
password, hasPassword := parsed.User.Password()
host, port := splitDatabaseAddress(parsed.Host)
if parsed.User.Username() != "smail" || !hasPassword || password != "p@ss:/?#[]" {
t.Fatalf("unexpected credentials in DSN: %q", dsn)
}
if host != "2001:db8::2" || port != "5432" || strings.TrimPrefix(parsed.Path, "/") != "smail_1" {
t.Fatalf("unexpected connection settings: host=%q port=%q name=%q", host, port, parsed.Path)
}
if parsed.Query().Get("sslmode") != "require" {
t.Fatalf("unexpected sslmode: %q", parsed.Query().Get("sslmode"))
}
}
func TestBuildPostgresDSNReusesMatchingPassword(t *testing.T) {
dsn, err := buildPostgresDSN(DatabaseSettings{
User: "smail",
Host: "127.0.0.1",
Port: "5432",
Name: "smail",
}, "postgres://smail:stored-secret@127.0.0.1:5432/smail?sslmode=require")
if err != nil {
t.Fatal(err)
}
parsed, ok := parsePostgresURL(dsn)
if !ok {
t.Fatalf("invalid PostgreSQL DSN: %q", dsn)
}
password, _ := parsed.User.Password()
if password != "stored-secret" || parsed.Query().Get("sslmode") != "require" {
t.Fatalf("stored PostgreSQL settings were not reused: %q", dsn)
}
}
func TestEnsurePostgresDatabaseCreatesMissingDatabase(t *testing.T) {
target := &fakeSetupDB{pingErrors: []error{&pq.Error{Code: "3D000"}, nil}}
admin := &fakeSetupDB{}
openCalls := 0
withPostgresSetupOpener(t, func(dsn string) (setupDatabase, error) {
openCalls++
parsed, ok := parsePostgresURL(dsn)
if !ok {
return nil, stderrors.New("invalid PostgreSQL test DSN")
}
if strings.TrimPrefix(parsed.Path, "/") == "postgres" {
return admin, nil
}
return target, nil
})
if err := ensurePostgresDatabase("postgres://smail:secret@127.0.0.1:5432/smail?sslmode=disable"); err != nil {
t.Fatal(err)
}
if openCalls != 2 || target.pingCalls != 2 || admin.pingCalls != 1 {
t.Fatalf("unexpected connection flow: opens=%d targetPings=%d adminPings=%d", openCalls, target.pingCalls, admin.pingCalls)
}
if len(admin.execQueries) != 1 || admin.execQueries[0] != `CREATE DATABASE "smail"` {
t.Fatalf("unexpected creation query: %#v", admin.execQueries)
}
}
func withPostgresSetupOpener(t *testing.T, opener func(string) (setupDatabase, error)) {
t.Helper()
original := openPostgresSetupDatabase
openPostgresSetupDatabase = opener
t.Cleanup(func() {
openPostgresSetupDatabase = original
})
}
func TestBuildMySQLDSNAppliesDefaults(t *testing.T) {
dsn, err := buildMySQLDSN(DatabaseSettings{User: "root"}, "")
if err != nil {
t.Fatal(err)
}
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if parsed.Addr != "127.0.0.1:3306" || parsed.DBName != "smail" {
t.Fatalf("unexpected defaults: address=%q database=%q", parsed.Addr, parsed.DBName)
}
}
func TestBuildMySQLDSNReusesMatchingPassword(t *testing.T) {
previous := mysql.NewConfig()
previous.User = "root"
previous.Passwd = "stored-secret"
previous.Net = "tcp"
previous.Addr = "[::1]:3306"
previous.DBName = "smail"
dsn, err := buildMySQLDSN(DatabaseSettings{
User: "root",
Host: "::1",
Port: "03306",
Name: "smail",
}, previous.FormatDSN())
if err != nil {
t.Fatal(err)
}
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if parsed.Passwd != "stored-secret" {
t.Fatal("matching stored password was not reused")
}
}
func TestBuildMySQLDSNRejectsInvalidInput(t *testing.T) {
tests := []DatabaseSettings{
{User: "root", Port: "0", Name: "smail"},
{User: "root", Port: "65536", Name: "smail"},
{User: "root", Port: "3306", Name: "smail-test"},
{User: "root", Host: "mysql server", Port: "3306", Name: "smail"},
{Port: "3306", Name: "smail"},
}
for _, settings := range tests {
if _, err := buildMySQLDSN(settings, ""); err == nil {
t.Fatalf("invalid settings were accepted: %+v", settings)
}
}
}
func TestBuildMySQLDSNAcceptsDatabaseServiceHost(t *testing.T) {
if _, err := buildMySQLDSN(DatabaseSettings{
User: "root",
Host: "mysql",
Port: "3306",
Name: "smail",
}, ""); err != nil {
t.Fatalf("database service host was rejected: %v", err)
}
}
func TestEnsureMySQLDatabaseSkipsCreationWhenDatabaseExists(t *testing.T) {
target := &fakeSetupDB{}
openCalls := 0
withMySQLSetupOpener(t, func(string) (setupDatabase, error) {
openCalls++
return target, nil
})
if err := ensureMySQLDatabase(testMySQLDSN(t, "secret")); err != nil {
t.Fatal(err)
}
if openCalls != 1 {
t.Fatalf("unexpected open count: %d", openCalls)
}
if len(target.execQueries) != 0 || !target.closed {
t.Fatal("existing database should not trigger creation and must be closed")
}
}
func TestEnsureMySQLDatabaseRejectsExistingNonEmptyDatabase(t *testing.T) {
target := &fakeSetupDB{tableCount: 1}
withMySQLSetupOpener(t, func(string) (setupDatabase, error) {
return target, nil
})
if err := ensureMySQLDatabase(testMySQLDSN(t, "secret")); err == nil {
t.Fatal("non-empty MySQL database was accepted for a fresh deployment")
} else if message := DatabaseSetupErrorMessage(err); message == "" {
t.Fatal("fresh deployment error message was empty")
}
}
func TestEnsurePostgresDatabaseRejectsExistingNonEmptyDatabase(t *testing.T) {
target := &fakeSetupDB{tableCount: 1}
withPostgresSetupOpener(t, func(string) (setupDatabase, error) {
return target, nil
})
if err := ensurePostgresDatabase("postgres://smail:secret@127.0.0.1:5432/smail?sslmode=disable"); err == nil {
t.Fatal("non-empty PostgreSQL database was accepted for a fresh deployment")
} else if message := DatabaseSetupErrorMessage(err); message == "" {
t.Fatal("fresh deployment error message was empty")
}
}
func TestEnsureMySQLDatabaseCreatesMissingDatabase(t *testing.T) {
target := &fakeSetupDB{pingErrors: []error{&mysql.MySQLError{Number: 1049}, nil}}
admin := &fakeSetupDB{}
openCalls := 0
withMySQLSetupOpener(t, func(dsn string) (setupDatabase, error) {
openCalls++
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
return nil, err
}
if parsed.DBName == "" {
return admin, nil
}
return target, nil
})
if err := ensureMySQLDatabase(testMySQLDSN(t, "secret")); err != nil {
t.Fatal(err)
}
if openCalls != 2 || target.pingCalls != 2 || admin.pingCalls != 1 {
t.Fatalf("unexpected connection flow: opens=%d targetPings=%d adminPings=%d", openCalls, target.pingCalls, admin.pingCalls)
}
if len(admin.execQueries) != 1 || admin.execQueries[0] != "CREATE DATABASE IF NOT EXISTS `smail` DEFAULT CHARACTER SET utf8mb4" {
t.Fatalf("unexpected creation query: %#v", admin.execQueries)
}
if !target.closed || !admin.closed {
t.Fatal("setup database connections were not closed")
}
}
func TestEnsureMySQLDatabaseReturnsSafeCreationError(t *testing.T) {
target := &fakeSetupDB{pingErrors: []error{&mysql.MySQLError{Number: 1049}}}
admin := &fakeSetupDB{execError: stderrors.New("secret-password")}
withMySQLSetupOpener(t, func(dsn string) (setupDatabase, error) {
parsed, err := mysql.ParseDSN(dsn)
if err != nil {
return nil, err
}
if parsed.DBName == "" {
return admin, nil
}
return target, nil
})
err := ensureMySQLDatabase(testMySQLDSN(t, "secret-password"))
if err == nil {
t.Fatal("database creation error was not returned")
}
message := DatabaseSetupErrorMessage(err)
if message == "" || strings.Contains(message, "secret-password") {
t.Fatalf("unsafe user-facing error message: %q", message)
}
}
func withMySQLSetupOpener(t *testing.T, opener func(string) (setupDatabase, error)) {
t.Helper()
original := openMySQLSetupDatabase
openMySQLSetupDatabase = opener
t.Cleanup(func() {
openMySQLSetupDatabase = original
})
}
func testMySQLDSN(t *testing.T, password string) string {
t.Helper()
dsn, err := buildMySQLDSN(DatabaseSettings{
User: "root",
Host: "127.0.0.1",
Port: "3306",
Password: password,
Name: "smail",
}, "")
if err != nil {
t.Fatal(err)
}
return dsn
}
func TestPrepareSQLiteDatabaseCreatesParentDirectory(t *testing.T) {
databasePath := filepath.Join(t.TempDir(), "nested", "config", "smail.db")
if err := prepareSQLiteDatabase(databasePath); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Dir(databasePath)); err != nil {
t.Fatalf("SQLite parent directory was not prepared: %v", err)
}
}
func TestDatabaseInitializationErrorReturnsMigrationGuidance(t *testing.T) {
for _, cause := range []error{migration.ErrChecksumMismatch, migration.ErrUnknownAppliedMigration} {
err := databaseInitializationError(cause)
message := DatabaseSetupErrorMessage(err)
if message != "数据库连接成功,但检测到历史迁移记录与当前版本不兼容,请先备份数据库后再执行兼容升级" {
t.Fatalf("message = %q", message)
}
if !stderrors.Is(err, cause) {
t.Fatalf("error does not preserve cause %v", cause)
}
}
}
func TestDatabaseInitializationErrorKeepsGenericErrorsInternal(t *testing.T) {
cause := stderrors.New("internal database detail")
err := databaseInitializationError(cause)
if message := DatabaseSetupErrorMessage(err); message != "" {
t.Fatalf("unexpected user-facing message = %q", message)
}
if !stderrors.Is(err, cause) {
t.Fatal("generic initialization error does not preserve its cause")
}
}