This commit is contained in:
parent
c1f064b4b6
commit
e97a8caade
@ -131,6 +131,10 @@ func configErrorDetails(err error) (string, int, string, map[string]string) {
|
||||
code = "DATABASE_MIGRATION_CONNECTION_UNAVAILABLE"
|
||||
status = http.StatusServiceUnavailable
|
||||
message = "数据库连接暂不可用,请检查数据库配置和服务状态"
|
||||
case errors.Is(err, dbmigration.ErrSQLPermissionDenied):
|
||||
code = "DATABASE_MIGRATION_PERMISSION_DENIED"
|
||||
status = http.StatusUnprocessableEntity
|
||||
message = "目标数据库账号权限不足,请联系数据库管理员授予建库权限后重新预览;PostgreSQL 需要 CREATEDB 权限,迁移完成后可撤销"
|
||||
case errors.Is(err, dbmigration.ErrSQLUnsupportedObject):
|
||||
code = "DATABASE_MIGRATION_UNSUPPORTED_OBJECT"
|
||||
status = http.StatusUnprocessableEntity
|
||||
|
||||
@ -139,6 +139,13 @@ func TestConfigErrorDetailsMapsDatabaseMigrationFailures(t *testing.T) {
|
||||
status: 503,
|
||||
message: "数据库连接暂不可用,请检查数据库配置和服务状态",
|
||||
},
|
||||
{
|
||||
name: "target creation permission denied",
|
||||
err: dbmigration.ErrSQLPermissionDenied,
|
||||
code: "DATABASE_MIGRATION_PERMISSION_DENIED",
|
||||
status: 422,
|
||||
message: "目标数据库账号权限不足,请联系数据库管理员授予建库权限后重新预览;PostgreSQL 需要 CREATEDB 权限,迁移完成后可撤销",
|
||||
},
|
||||
{
|
||||
name: "unsupported object",
|
||||
err: dbmigration.ErrSQLUnsupportedObject,
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
package dbmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
mysql "github.com/go-sql-driver/mysql"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
@ -32,3 +40,135 @@ func TestPortablePostgresUsesSupportedRuntimeTLSMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseCreationErrorClassification(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
kind config.DatabaseType
|
||||
err error
|
||||
want error
|
||||
}{
|
||||
{"postgres permission", config.DatabaseTypePostgreSQL, &pq.Error{Code: "42501"}, ErrSQLPermissionDenied},
|
||||
{"postgres wrapped permission", config.DatabaseTypePostgreSQL, fmt.Errorf("private detail: %w", &pq.Error{Code: "42501"}), ErrSQLPermissionDenied},
|
||||
{"postgres duplicate", config.DatabaseTypePostgreSQL, &pq.Error{Code: "42P04"}, ErrSQLTargetExists},
|
||||
{"mysql database permission", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1044}, ErrSQLPermissionDenied},
|
||||
{"mysql command permission", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1142}, ErrSQLPermissionDenied},
|
||||
{"mysql privilege", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1227}, ErrSQLPermissionDenied},
|
||||
{"mysql duplicate", config.DatabaseTypeMySQL, &mysql.MySQLError{Number: 1007}, ErrSQLTargetExists},
|
||||
{"authentication", config.DatabaseTypePostgreSQL, &pq.Error{Code: "28P01"}, ErrSQLConnectionUnavailable},
|
||||
{"network", config.DatabaseTypePostgreSQL, errors.New("private connection detail"), ErrSQLConnectionUnavailable},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := classifyDatabaseCreationError(test.err, test.kind); got != test.want {
|
||||
t.Fatalf("classification = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type permissionCatalog struct {
|
||||
exists int64
|
||||
allowed bool
|
||||
queryErr error
|
||||
createErr error
|
||||
queries int
|
||||
creates int
|
||||
}
|
||||
|
||||
func (c *permissionCatalog) Connect(context.Context) (driver.Conn, error) { return c, nil }
|
||||
func (c *permissionCatalog) Driver() driver.Driver { return c }
|
||||
func (c *permissionCatalog) Open(string) (driver.Conn, error) { return c, nil }
|
||||
func (c *permissionCatalog) Close() error { return nil }
|
||||
func (c *permissionCatalog) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("unexpected prepare")
|
||||
}
|
||||
func (c *permissionCatalog) Begin() (driver.Tx, error) {
|
||||
return nil, errors.New("unexpected transaction")
|
||||
}
|
||||
func (c *permissionCatalog) QueryContext(_ context.Context, query string, args []driver.NamedValue) (driver.Rows, error) {
|
||||
switch query {
|
||||
case "SELECT COUNT(*) FROM pg_database WHERE datname = $1":
|
||||
if len(args) != 1 || args[0].Value != "smail_next" {
|
||||
return nil, errors.New("unexpected target name")
|
||||
}
|
||||
return &permissionCatalogRow{value: c.exists}, nil
|
||||
case "SELECT rolcreatedb OR rolsuper FROM pg_roles WHERE rolname = CURRENT_USER":
|
||||
c.queries++
|
||||
if c.queryErr != nil {
|
||||
return nil, c.queryErr
|
||||
}
|
||||
return &permissionCatalogRow{value: c.allowed}, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected catalog query")
|
||||
}
|
||||
}
|
||||
func (c *permissionCatalog) ExecContext(_ context.Context, query string, _ []driver.NamedValue) (driver.Result, error) {
|
||||
if !strings.HasPrefix(query, "CREATE DATABASE ") {
|
||||
return nil, errors.New("unexpected write")
|
||||
}
|
||||
c.creates++
|
||||
return nil, c.createErr
|
||||
}
|
||||
|
||||
type permissionCatalogRow struct {
|
||||
value driver.Value
|
||||
done bool
|
||||
}
|
||||
|
||||
func (r *permissionCatalogRow) Columns() []string { return []string{"value"} }
|
||||
func (r *permissionCatalogRow) Close() error { return nil }
|
||||
func (r *permissionCatalogRow) Next(values []driver.Value) error {
|
||||
if r.done {
|
||||
return io.EOF
|
||||
}
|
||||
r.done = true
|
||||
values[0] = r.value
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPortablePostgresPermissionPreviewIsReadOnly(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
catalog permissionCatalog
|
||||
want error
|
||||
}{
|
||||
{"denied", permissionCatalog{}, ErrSQLPermissionDenied},
|
||||
{"allowed", permissionCatalog{allowed: true}, nil},
|
||||
{"existing", permissionCatalog{exists: 1}, ErrSQLTargetExists},
|
||||
{"catalog unavailable", permissionCatalog{queryErr: errors.New("unavailable")}, ErrSQLConnectionUnavailable},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root, source := portableFixture(t)
|
||||
target := testDatabase(t)
|
||||
target.Type = config.DatabaseTypePostgreSQL
|
||||
target.Name = "smail_next"
|
||||
plan, err := buildTargetPlan(source, target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
executor := &PortableExecutor{root: root, open: func(context.Context, config.DatabaseConfig, string) (*sql.DB, error) {
|
||||
return sql.OpenDB(&test.catalog), nil
|
||||
}}
|
||||
if err := executor.Preview(context.Background(), plan); !errors.Is(err, test.want) {
|
||||
t.Fatalf("preview = %v, want %v", err, test.want)
|
||||
}
|
||||
if test.catalog.creates != 0 || executor.paused.Load() || executor.pending {
|
||||
t.Fatal("preview wrote target or paused source")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortablePostgresCreationHandlesPermissionRevocation(t *testing.T) {
|
||||
catalog := &permissionCatalog{allowed: true, createErr: &pq.Error{Code: "42501", Message: "private database detail"}}
|
||||
executor := &PortableExecutor{open: func(context.Context, config.DatabaseConfig, string) (*sql.DB, error) {
|
||||
return sql.OpenDB(catalog), nil
|
||||
}}
|
||||
target := testDatabase(t)
|
||||
target.Type = config.DatabaseTypePostgreSQL
|
||||
target.Name = "smail_next"
|
||||
connection, err := executor.createTarget(context.Background(), target)
|
||||
if connection != nil || err != ErrSQLPermissionDenied || catalog.creates != 1 || catalog.queries != 1 {
|
||||
t.Fatalf("creation did not preserve permission failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -96,6 +96,16 @@ func (e *PortableExecutor) checkTarget(ctx context.Context, target config.Databa
|
||||
if count != 0 {
|
||||
return ErrSQLTargetExists
|
||||
}
|
||||
if target.Type == config.DatabaseTypePostgreSQL {
|
||||
var canCreate bool
|
||||
query := "SELECT rolcreatedb OR rolsuper FROM pg_roles WHERE rolname = CURRENT_USER"
|
||||
if err := admin.QueryRowContext(ctx, query).Scan(&canCreate); err != nil {
|
||||
return classifyDatabaseCreationError(err, target.Type)
|
||||
}
|
||||
if !canCreate {
|
||||
return ErrSQLPermissionDenied
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -129,10 +139,7 @@ func (e *PortableExecutor) createTarget(ctx context.Context, target config.Datab
|
||||
query += " TEMPLATE template0 ENCODING 'UTF8'"
|
||||
}
|
||||
if _, err := admin.ExecContext(ctx, query); err != nil {
|
||||
if isDatabaseAlreadyExistsError(err, target.Type) {
|
||||
return nil, ErrSQLTargetExists
|
||||
}
|
||||
return nil, ErrSQLConnectionUnavailable
|
||||
return nil, classifyDatabaseCreationError(err, target.Type)
|
||||
}
|
||||
}
|
||||
// 失败目标保留供检查,后续重试必须换用未占用的名称,绝不删除旧库。
|
||||
|
||||
@ -372,6 +372,8 @@ func migrationErrorCode(err error) string {
|
||||
return "COPIER_UNAVAILABLE"
|
||||
case errors.Is(err, ErrSQLConnectionUnavailable):
|
||||
return "CONNECTION_UNAVAILABLE"
|
||||
case errors.Is(err, ErrSQLPermissionDenied):
|
||||
return "PERMISSION_DENIED"
|
||||
case errors.Is(err, ErrSQLUnsupportedObject):
|
||||
return "UNSUPPORTED_OBJECT"
|
||||
case errors.Is(err, ErrSQLDataVerification):
|
||||
|
||||
@ -119,6 +119,7 @@ func TestMigrationErrorCodeMapsExecutorFailures(t *testing.T) {
|
||||
{err: ErrSQLTargetExists, code: "TARGET_EXISTS"},
|
||||
{err: ErrSQLCopyUnavailable, code: "COPIER_UNAVAILABLE"},
|
||||
{err: ErrSQLConnectionUnavailable, code: "CONNECTION_UNAVAILABLE"},
|
||||
{err: ErrSQLPermissionDenied, code: "PERMISSION_DENIED"},
|
||||
{err: ErrSQLUnsupportedObject, code: "UNSUPPORTED_OBJECT"},
|
||||
{err: ErrSQLDataVerification, code: "DATA_VERIFICATION_FAILED"},
|
||||
{err: ErrSQLSourceBusy, code: "SOURCE_BUSY"},
|
||||
|
||||
@ -25,6 +25,7 @@ var (
|
||||
ErrSQLTargetExists = errors.New("structured database migration target already exists")
|
||||
ErrSQLUnsupportedObject = errors.New("structured database contains unsupported object")
|
||||
ErrSQLConnectionUnavailable = errors.New("structured database connection unavailable")
|
||||
ErrSQLPermissionDenied = errors.New("structured database migration permission denied")
|
||||
ErrSQLDataVerification = errors.New("structured database data verification failed")
|
||||
ErrSQLSourceBusy = errors.New("structured database source has active connections")
|
||||
ErrSwitchOutcomeUnknown = errors.New("database configuration switch outcome is unknown")
|
||||
@ -750,6 +751,25 @@ func listPostgreSQLTables(ctx context.Context, database *sql.DB) ([]postgreSQLTa
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
func classifyDatabaseCreationError(err error, databaseType config.DatabaseType) error {
|
||||
if isDatabaseAlreadyExistsError(err, databaseType) {
|
||||
return ErrSQLTargetExists
|
||||
}
|
||||
switch databaseType {
|
||||
case config.DatabaseTypePostgreSQL:
|
||||
var postgresErr *pq.Error
|
||||
if errors.As(err, &postgresErr) && postgresErr.Code == "42501" {
|
||||
return ErrSQLPermissionDenied
|
||||
}
|
||||
case config.DatabaseTypeMySQL:
|
||||
var mysqlErr *mysql.MySQLError
|
||||
if errors.As(err, &mysqlErr) && (mysqlErr.Number == 1044 || mysqlErr.Number == 1142 || mysqlErr.Number == 1227) {
|
||||
return ErrSQLPermissionDenied
|
||||
}
|
||||
}
|
||||
return ErrSQLConnectionUnavailable
|
||||
}
|
||||
|
||||
func isDatabaseAlreadyExistsError(err error, databaseType config.DatabaseType) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
||||
Loading…
Reference in New Issue
Block a user