完善数据库迁移结构校验
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-08-31 13:06:26 +08:00
parent e9981cff70
commit 040d353cd4
5 changed files with 492 additions and 48 deletions

View File

@ -870,3 +870,21 @@ go vet ./...
- **修改范围**:`server/models/user_email.go`、`server/db/migration/imap_flags.go`、`server/services/list/list.go`、`server/services/del_email/del_email.go`、`server/listen/imap_server/session_*.go` 及相关测试。
- **自动化验证**:`go test ./services/list ./listen/imap_server ./db/migration ./services/del_email -count=1 -timeout 10m` 已通过;本轮最终提交前继续执行服务端全量测试、竞态测试、`go vet`、前端 ESLint 和生产构建。
- **开放项**:HSTS 与 Nginx 反向代理的实际上线行为、真实 IMAP 客户端兼容性、Ubuntu/Windows 部署和真实 MySQL/PostgreSQL 迁移仍需实机复审,不据此升级为最终发布通过。
## 2026-08-31 数据库迁移输入与结构校验增量整改记录
- **关联任务**:SEC-003、SEC-015、SEC-022,以及配置迁移安全复核项。
- **本轮状态**:`[x] 已完成,等待真实数据库与部署复审`。
- **实施内容**:数据库复制器新增独立入口校验,防止调用方绕过配置服务直接提交无效源库、非法目标库、跨连接目标或不支持的数据库类型。
- **验证内容**:MySQL 复制后校验表集合和列定义;PostgreSQL 复制后校验表集合、列定义和行数;结构不一致统一返回数据校验失败,不继续切换运行时配置。
- **自动化验证**:数据库迁移专项测试通过,未将真实 MySQL、PostgreSQL、Ubuntu、Windows 和生产网络验收标记为通过。
- **遗留边界**:真实数据库权限、网络中断、超大数据量、Nginx 反向代理和生产发布安全审查仍需在目标环境中完成。
## 2026-08-31 MySQL 外键结构最终校验补强记录
- **关联任务**:SEC-003、SEC-015、SEC-022,以及数据库迁移安全复核项。
- **本轮状态**:`[x] 已完成,等待真实数据库与部署复审`。
- **实施内容**:MySQL 数据复制完成后新增外键最终校验,逐项比较外键所在表、约束名称、外键列顺序、引用库表列顺序以及更新和删除规则;源库内部引用在目标库校验时自动映射到目标库名称。
- **一致性边界**:外键缺失、多出、列顺序变化、引用对象变化或级联规则变化都会返回数据校验失败,并阻止后续配置切换。
- **自动化验证**:新增等价引用、顺序无关比较、内部库名映射、缺失外键、多出外键和规则差异测试;数据库迁移专项测试、服务端全量测试、`go vet`、全量竞态检测、前端 ESLint 和生产构建均通过。
- **实机边界**:真实 MySQL、PostgreSQL、SQLite、Ubuntu、Windows、权限差异、网络中断、超大数据量和生产回滚仍需在目标环境复审,不能据此标记最终发布通过。
- **复审日期**:2026-08-31(本机自动化复审)。

View File

@ -1306,3 +1306,25 @@ CG-053~CG-056 → AD-066 → CG-057
### 发布结论
配置系统重构的工作区代码已进入待复审状态,但 CG-045、CG-051、CG-056 和 CG-057 仍受真实浏览器、跨平台、三数据库、多实例和生产发布评审阻塞。不得将“代码完成”表述为“生产验收完成”。
## 2026-08-31 数据库迁移执行器入口与结构校验整改记录
- **关联任务**:CG-003、CG-051、CG-052~CG-055。
- **本轮状态**:`[x] 已完成,等待复审`。
- **实施内容**:统一 `SQLExecutor` 与 `SQLCopier` 的数据库复制请求校验;直接调用复制器时也会拒绝无效数据库配置、类型不一致、非法库名、同名目标库和跨连接目标,避免绕过服务层产生不安全 SQL 操作。
- **结构校验**:MySQL 复制完成后复核源库和目标库的表集合、表列顺序、列名、类型、可空性、默认值、额外属性和键属性;PostgreSQL 复制完成后复核表集合以及列定义,再逐表复核行数。
- **自动化测试**:新增入口校验、危险库名、同名库、跨主机、类型不支持和表集合精确比较测试;`go test ./services/dbmigration -count=1` 通过。
- **安全检查**:库名仍仅允许安全标识符;数据库名称只通过驱动参数或安全引用进入 SQL;未新增 HTTP 重置入口、未放宽凭据存储、未删除任何数据文件。
- **跨平台或数据库覆盖**:当前为代码级和 SQLite 测试环境验证;真实 MySQL、PostgreSQL、Ubuntu、Windows、生产权限和故障恢复仍需实机复审。
- **遗留问题**:真实三数据库复制、权限差异、超大库耗时、网络中断后的生产回滚和原子切换尚未完成实机验收。
- **结束状态**:待复审。
- **复审人**:待人工复审。
## 2026-08-31 MySQL 外键结构最终校验补强记录
- **关联任务**:CG-003、CG-051、CG-052~CG-055。
- **本轮状态**:`[x] 已完成,等待真实数据库与部署复审`。
- **实施内容**:MySQL 结构化复制器在复制外键后,重新读取源库和目标库的外键元数据并进行精确集合比较;同时保留安全库名、同类型、同连接和目标库名称校验。
- **校验字段**:比较表名、约束名、外键列顺序、引用 schema、引用表、引用列顺序、`ON UPDATE` 和 `ON DELETE` 规则;源库内部 schema 会映射为目标库 schema 后再比较。
- **自动化验证**:新增外键等价比较和差异拒绝测试;`go test ./... -count=1 -timeout 30m`、`go test -race ./... -count=1 -timeout 45m`、`go vet ./...`、前端 ESLint 和生产构建均通过。
- **发布边界**:真实 MySQL/PostgreSQL/SQLite 迁移、跨平台部署、Nginx、浏览器、邮件系统、多实例和生产故障恢复仍待实机复审;CG-051、CG-056、CG-057 不升级为最终复审通过。
- **复审日期**:2026-08-31(本机自动化复审)。

View File

@ -44,7 +44,6 @@
</div>
</section>
<el-divider class="settings-divider" />
<el-divider class="settings-divider" />
<section class="security-section protocol-section">
<div class="settings-header">

View File

@ -8,6 +8,7 @@ import (
"net"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@ -84,21 +85,8 @@ func (e *SQLExecutor) Run(ctx context.Context, plan Plan, update func(Status) er
if err := contextError(ctx); err != nil {
return err
}
if plan.sourceConfig.Type != plan.targetConfig.Type ||
(plan.sourceConfig.Type != config.DatabaseTypeMySQL && plan.sourceConfig.Type != config.DatabaseTypePostgreSQL) {
return ErrUnsupportedDatabase
}
if err := config.ValidateDatabaseConfig(plan.sourceConfig); err != nil {
return ErrSourceDatabase
}
if err := config.ValidateDatabaseConfig(plan.targetConfig); err != nil {
return ErrInvalidTargetName
}
if !validStructuredDatabaseName(plan.sourceConfig.Name) ||
!validStructuredDatabaseName(plan.targetConfig.Name) ||
plan.sourceConfig.Name == plan.targetConfig.Name ||
!sameStructuredConnection(plan.sourceConfig, plan.targetConfig) {
return ErrImmutableSetting
if err := validateSQLCopyRequest(plan.sourceConfig, plan.targetConfig); err != nil {
return err
}
if e.copier == nil {
return ErrSQLCopyUnavailable
@ -187,6 +175,9 @@ func (c *SQLCopier) Copy(ctx context.Context, source, target config.DatabaseConf
if err := contextError(ctx); err != nil {
return err
}
if err := validateSQLCopyRequest(source, target); err != nil {
return err
}
adminDB, err := c.open(ctx, source, "")
if err != nil || adminDB == nil {
return ErrSQLConnectionUnavailable
@ -295,9 +286,15 @@ func (c *SQLCopier) copyMySQL(ctx context.Context, sourceDB, adminDB *sql.DB, so
return fmt.Errorf("%w: mysql table %s row count changed during migration", ErrSQLDataVerification, tableName)
}
}
if err := verifyMySQLTableStructure(ctx, adminDB, sourceName, targetName, tableNames); err != nil {
return err
}
if err := c.copyMySQLForeignKeys(ctx, adminDB, sourceName, targetName, tableNames); err != nil {
return err
}
if err := verifyMySQLForeignKeys(ctx, adminDB, sourceName, targetName); err != nil {
return err
}
created = false
return nil
}
@ -347,38 +344,10 @@ func (c *SQLCopier) copyMySQLForeignKeys(ctx context.Context, database *sql.DB,
if len(tableNames) == 0 {
return nil
}
rows, err := database.QueryContext(ctx, "SELECT kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.REFERENCED_TABLE_SCHEMA, kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME, rc.UPDATE_RULE, rc.DELETE_RULE FROM information_schema.KEY_COLUMN_USAGE kcu JOIN information_schema.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA AND tc.TABLE_NAME = kcu.TABLE_NAME AND tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME JOIN information_schema.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND rc.TABLE_NAME = kcu.TABLE_NAME WHERE tc.CONSTRAINT_SCHEMA = ? AND tc.CONSTRAINT_TYPE = 'FOREIGN KEY' AND kcu.REFERENCED_TABLE_NAME IS NOT NULL ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION", sourceName)
foreignKeys, err := listMySQLForeignKeys(ctx, database, sourceName)
if err != nil {
return fmt.Errorf("%w: inspect mysql foreign keys", ErrSQLConnectionUnavailable)
}
defer rows.Close()
foreignKeys := make([]mysqlForeignKey, 0)
indexes := make(map[string]int)
for rows.Next() {
if err := contextError(ctx); err != nil {
return err
}
var item mysqlForeignKey
var column, referencedColumn string
if err := rows.Scan(&item.tableName, &item.constraintName, &column, &item.referencedSchema, &item.referencedTable, &referencedColumn, &item.updateRule, &item.deleteRule); err != nil {
return fmt.Errorf("%w: read mysql foreign keys", ErrSQLConnectionUnavailable)
}
if !validMySQLReferenceRule(item.updateRule) || !validMySQLReferenceRule(item.deleteRule) {
return ErrSQLUnsupportedObject
}
key := item.tableName + "\x00" + item.constraintName
index, ok := indexes[key]
if !ok {
indexes[key] = len(foreignKeys)
foreignKeys = append(foreignKeys, item)
index = len(foreignKeys) - 1
}
foreignKeys[index].columns = append(foreignKeys[index].columns, column)
foreignKeys[index].referencedColumns = append(foreignKeys[index].referencedColumns, referencedColumn)
}
if err := rows.Err(); err != nil {
return fmt.Errorf("%w: read mysql foreign keys", ErrSQLConnectionUnavailable)
}
for _, foreignKey := range foreignKeys {
if err := contextError(ctx); err != nil {
return err
@ -397,6 +366,91 @@ func (c *SQLCopier) copyMySQLForeignKeys(ctx context.Context, database *sql.DB,
return nil
}
func verifyMySQLForeignKeys(ctx context.Context, database *sql.DB, sourceName, targetName string) error {
source, err := listMySQLForeignKeys(ctx, database, sourceName)
if err != nil {
return fmt.Errorf("%w: list mysql source foreign keys for verification", ErrSQLConnectionUnavailable)
}
target, err := listMySQLForeignKeys(ctx, database, targetName)
if err != nil {
return fmt.Errorf("%w: list mysql target foreign keys for verification", ErrSQLConnectionUnavailable)
}
if !sameMySQLForeignKeys(source, target, sourceName, targetName) {
return fmt.Errorf("%w: mysql foreign key structure changed during migration", ErrSQLDataVerification)
}
return nil
}
func listMySQLForeignKeys(ctx context.Context, database *sql.DB, schemaName string) ([]mysqlForeignKey, error) {
rows, err := database.QueryContext(ctx, "SELECT kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.REFERENCED_TABLE_SCHEMA, kcu.REFERENCED_TABLE_NAME, kcu.REFERENCED_COLUMN_NAME, rc.UPDATE_RULE, rc.DELETE_RULE FROM information_schema.KEY_COLUMN_USAGE kcu JOIN information_schema.TABLE_CONSTRAINTS tc ON tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA AND tc.TABLE_NAME = kcu.TABLE_NAME AND tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME JOIN information_schema.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME AND rc.TABLE_NAME = kcu.TABLE_NAME WHERE tc.CONSTRAINT_SCHEMA = ? AND tc.CONSTRAINT_TYPE = 'FOREIGN KEY' AND kcu.REFERENCED_TABLE_NAME IS NOT NULL ORDER BY kcu.TABLE_NAME, kcu.CONSTRAINT_NAME, kcu.ORDINAL_POSITION", schemaName)
if err != nil {
return nil, err
}
defer rows.Close()
foreignKeys := make([]mysqlForeignKey, 0)
indexes := make(map[string]int)
for rows.Next() {
if err := contextError(ctx); err != nil {
return nil, err
}
var item mysqlForeignKey
var column, referencedColumn string
if err := rows.Scan(&item.tableName, &item.constraintName, &column, &item.referencedSchema, &item.referencedTable, &referencedColumn, &item.updateRule, &item.deleteRule); err != nil {
return nil, err
}
if !validMySQLReferenceRule(item.updateRule) || !validMySQLReferenceRule(item.deleteRule) {
return nil, ErrSQLUnsupportedObject
}
key := item.tableName + "\x00" + item.constraintName
index, ok := indexes[key]
if !ok {
indexes[key] = len(foreignKeys)
foreignKeys = append(foreignKeys, item)
index = len(foreignKeys) - 1
}
foreignKeys[index].columns = append(foreignKeys[index].columns, column)
foreignKeys[index].referencedColumns = append(foreignKeys[index].referencedColumns, referencedColumn)
}
if err := rows.Err(); err != nil {
return nil, err
}
return foreignKeys, nil
}
func sameMySQLForeignKeys(source, target []mysqlForeignKey, sourceName, targetName string) bool {
if len(source) != len(target) {
return false
}
sourceSignatures := make([]string, len(source))
for index, item := range source {
sourceSignatures[index] = mysqlForeignKeySignature(item, sourceName, targetName)
}
targetSignatures := make([]string, len(target))
for index, item := range target {
targetSignatures[index] = mysqlForeignKeySignature(item, sourceName, targetName)
}
sort.Strings(sourceSignatures)
sort.Strings(targetSignatures)
return sameStringSet(sourceSignatures, targetSignatures)
}
func mysqlForeignKeySignature(item mysqlForeignKey, sourceName, targetName string) string {
referencedSchema := item.referencedSchema
if referencedSchema == sourceName {
referencedSchema = targetName
}
return strings.Join([]string{
item.tableName,
item.constraintName,
strings.Join(item.columns, "\x00"),
referencedSchema,
item.referencedTable,
strings.Join(item.referencedColumns, "\x00"),
strings.ToUpper(strings.TrimSpace(item.updateRule)),
strings.ToUpper(strings.TrimSpace(item.deleteRule)),
}, "\x00")
}
func validMySQLReferenceRule(value string) bool {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION":
@ -478,10 +532,21 @@ func (c *SQLCopier) verifyPostgreSQLClone(ctx context.Context, source, target co
if err != nil {
return fmt.Errorf("%w: list postgresql target tables", ErrSQLConnectionUnavailable)
}
if len(tables) != len(targetTables) {
return fmt.Errorf("%w: postgresql table count changed during migration", ErrSQLDataVerification)
if !samePostgreSQLTables(tables, targetTables) {
return fmt.Errorf("%w: postgresql table structure changed during migration", ErrSQLDataVerification)
}
for _, table := range tables {
sourceColumns, err := listPostgreSQLColumns(ctx, sourceDB, table)
if err != nil {
return fmt.Errorf("%w: list postgresql source columns", ErrSQLConnectionUnavailable)
}
targetColumns, err := listPostgreSQLColumns(ctx, targetDB, table)
if err != nil {
return fmt.Errorf("%w: list postgresql target columns", ErrSQLConnectionUnavailable)
}
if !sameStringSet(sourceColumns, targetColumns) {
return fmt.Errorf("%w: postgresql table %s columns changed during migration", ErrSQLDataVerification, table.name)
}
sourceCount, err := countRows(ctx, sourceDB, quotePostgreSQLIdentifier(table.schema)+"."+quotePostgreSQLIdentifier(table.name))
if err != nil {
return fmt.Errorf("%w: count postgresql source rows", ErrSQLConnectionUnavailable)
@ -497,6 +562,162 @@ func (c *SQLCopier) verifyPostgreSQLClone(ctx context.Context, source, target co
return nil
}
func validateSQLCopyRequest(source, target config.DatabaseConfig) error {
source = config.NormalizeDatabaseConfig(source)
target = config.NormalizeDatabaseConfig(target)
if source.Type != target.Type ||
(source.Type != config.DatabaseTypeMySQL && source.Type != config.DatabaseTypePostgreSQL) {
return ErrUnsupportedDatabase
}
if err := config.ValidateDatabaseConfig(source); err != nil {
return ErrSourceDatabase
}
if err := config.ValidateDatabaseConfig(target); err != nil {
return ErrInvalidTargetName
}
if !validStructuredDatabaseName(source.Name) {
return ErrSourceDatabase
}
if !validStructuredDatabaseName(target.Name) {
return ErrInvalidTargetName
}
if source.Name == target.Name ||
!sameStructuredConnection(source, target) {
return ErrImmutableSetting
}
return nil
}
func verifyMySQLTableStructure(ctx context.Context, database *sql.DB, sourceName, targetName string, tableNames []string) error {
sourceTables, err := listMySQLTables(ctx, database, sourceName)
if err != nil {
return fmt.Errorf("%w: list mysql source tables for verification", ErrSQLConnectionUnavailable)
}
targetTables, err := listMySQLTables(ctx, database, targetName)
if err != nil {
return fmt.Errorf("%w: list mysql target tables for verification", ErrSQLConnectionUnavailable)
}
if !sameStringSet(sourceTables, targetTables) || !sameStringSet(sourceTables, tableNames) {
return fmt.Errorf("%w: mysql table structure changed during migration", ErrSQLDataVerification)
}
for _, tableName := range sourceTables {
sourceColumns, err := listMySQLColumns(ctx, database, sourceName, tableName)
if err != nil {
return fmt.Errorf("%w: list mysql source columns", ErrSQLConnectionUnavailable)
}
targetColumns, err := listMySQLColumns(ctx, database, targetName, tableName)
if err != nil {
return fmt.Errorf("%w: list mysql target columns", ErrSQLConnectionUnavailable)
}
if !sameStringSet(sourceColumns, targetColumns) {
return fmt.Errorf("%w: mysql table %s columns changed during migration", ErrSQLDataVerification, tableName)
}
}
return nil
}
func listMySQLTables(ctx context.Context, database *sql.DB, databaseName string) ([]string, error) {
rows, err := database.QueryContext(ctx, "SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_SCHEMA = ? AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME", databaseName)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0)
for rows.Next() {
if err := contextError(ctx); err != nil {
return nil, err
}
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func listMySQLColumns(ctx context.Context, database *sql.DB, databaseName, tableName string) ([]string, error) {
rows, err := database.QueryContext(ctx, "SELECT ORDINAL_POSITION, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COALESCE(COLUMN_DEFAULT, ''), EXTRA, COLUMN_KEY FROM information_schema.columns WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION", databaseName, tableName)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0)
for rows.Next() {
if err := contextError(ctx); err != nil {
return nil, err
}
var ordinal int
var name, columnType, nullable, defaultValue, extra, key string
if err := rows.Scan(&ordinal, &name, &columnType, &nullable, &defaultValue, &extra, &key); err != nil {
return nil, err
}
result = append(result, fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%s\x00%s", ordinal, name, columnType, nullable, defaultValue, extra, key))
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func listPostgreSQLColumns(ctx context.Context, database *sql.DB, table postgreSQLTable) ([]string, error) {
rows, err := database.QueryContext(ctx, "SELECT ordinal_position, column_name, data_type, udt_name, is_nullable, COALESCE(column_default, ''), COALESCE(character_maximum_length, -1), COALESCE(numeric_precision, -1), COALESCE(numeric_scale, -1) FROM information_schema.columns WHERE table_schema = $1 AND table_name = $2 ORDER BY ordinal_position", table.schema, table.name)
if err != nil {
return nil, err
}
defer rows.Close()
result := make([]string, 0)
for rows.Next() {
if err := contextError(ctx); err != nil {
return nil, err
}
var ordinal, characterLength, precision, scale int
var name, dataType, udtName, nullable, defaultValue string
if err := rows.Scan(&ordinal, &name, &dataType, &udtName, &nullable, &defaultValue, &characterLength, &precision, &scale); err != nil {
return nil, err
}
result = append(result, fmt.Sprintf("%d\x00%s\x00%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%d", ordinal, name, dataType, udtName, nullable, defaultValue, characterLength, precision, scale))
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func samePostgreSQLTables(source, target []postgreSQLTable) bool {
if len(source) != len(target) {
return false
}
seen := make(map[string]struct{}, len(source))
for _, table := range source {
seen[table.schema+"\x00"+table.name] = struct{}{}
}
for _, table := range target {
if _, ok := seen[table.schema+"\x00"+table.name]; !ok {
return false
}
}
return true
}
func sameStringSet(source, target []string) bool {
if len(source) != len(target) {
return false
}
seen := make(map[string]struct{}, len(source))
for _, value := range source {
seen[value] = struct{}{}
}
for _, value := range target {
if _, ok := seen[value]; !ok {
return false
}
}
return true
}
type postgreSQLTable struct {
schema string
name string

View File

@ -44,7 +44,10 @@ func TestSQLCopierRejectsNilDatabaseWithoutPanicking(t *testing.T) {
copier := NewSQLCopier(func(context.Context, config.DatabaseConfig, string) (*sql.DB, error) {
return nil, nil
})
if err := copier.Copy(context.Background(), testDatabase(t), testDatabase(t)); !errors.Is(err, ErrSQLConnectionUnavailable) {
source := testDatabase(t)
target := source
target.Name = "smail_next"
if err := copier.Copy(context.Background(), source, target); !errors.Is(err, ErrSQLConnectionUnavailable) {
t.Fatalf("nil admin database error = %v, want connection unavailable", err)
}
}
@ -145,6 +148,78 @@ func TestStructuredDatabaseNameValidation(t *testing.T) {
}
}
func TestValidateSQLCopyRequestRejectsUnsafeOrCrossConnectionTargets(t *testing.T) {
source := testDatabase(t)
validTarget := source
validTarget.Name = "smail_next"
if err := validateSQLCopyRequest(source, validTarget); err != nil {
t.Fatalf("valid copy request rejected: %v", err)
}
tests := []struct {
name string
mutate func(*config.DatabaseConfig, *config.DatabaseConfig)
want error
}{
{
name: "same database",
mutate: func(_, target *config.DatabaseConfig) {
target.Name = "smail"
},
want: ErrImmutableSetting,
},
{
name: "unsafe target name",
mutate: func(_, target *config.DatabaseConfig) {
target.Name = "smail;drop"
},
want: ErrInvalidTargetName,
},
{
name: "different host",
mutate: func(_, target *config.DatabaseConfig) {
target.Host = "other.internal"
},
want: ErrImmutableSetting,
},
{
name: "unsupported type",
mutate: func(_, target *config.DatabaseConfig) {
target.Type = config.DatabaseTypeSQLite
},
want: ErrUnsupportedDatabase,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := validTarget
test.mutate(&source, &candidate)
if err := validateSQLCopyRequest(source, candidate); !errors.Is(err, test.want) {
t.Fatalf("validation error = %v, want %v", err, test.want)
}
})
}
}
func TestStructuredTableComparisonsRequireExactSets(t *testing.T) {
if !sameStringSet([]string{"users", "messages"}, []string{"messages", "users"}) {
t.Fatal("same string set rejected equal unordered values")
}
if sameStringSet([]string{"users"}, []string{"users", "messages"}) {
t.Fatal("same string set accepted different lengths")
}
if sameStringSet([]string{"users"}, []string{"accounts"}) {
t.Fatal("same string set accepted different values")
}
source := []postgreSQLTable{{schema: "public", name: "messages"}}
if !samePostgreSQLTables(source, []postgreSQLTable{{schema: "public", name: "messages"}}) {
t.Fatal("same postgres table set rejected equal values")
}
if samePostgreSQLTables(source, []postgreSQLTable{{schema: "archive", name: "messages"}}) {
t.Fatal("same postgres table set accepted different schemas")
}
}
func TestDatabaseExecutorRoutesByDatabaseType(t *testing.T) {
var sqliteCalled, structuredCalled bool
sqlite := runnerFunc(func(context.Context, Plan, func(Status) error) error {
@ -343,3 +418,112 @@ func TestMySQLReferenceRulesAndIdentifiersAreRestrictedSafely(t *testing.T) {
t.Fatalf("quoted mysql identifiers = %q", got)
}
}
func TestMySQLForeignKeyComparisonsNormalizeDatabaseReferences(t *testing.T) {
source := []mysqlForeignKey{
{
tableName: "messages",
constraintName: "fk_messages_user",
columns: []string{"user_id"},
referencedSchema: "smail",
referencedTable: "users",
referencedColumns: []string{"id"},
updateRule: "NO ACTION",
deleteRule: "CASCADE",
},
{
tableName: "user_aliases",
constraintName: "fk_aliases_user",
columns: []string{"user_id", "tenant_id"},
referencedSchema: "directory",
referencedTable: "users",
referencedColumns: []string{"id", "tenant_id"},
updateRule: "RESTRICT",
deleteRule: "SET NULL",
},
}
target := []mysqlForeignKey{
source[1],
{
tableName: "messages",
constraintName: "fk_messages_user",
columns: []string{"user_id"},
referencedSchema: "smail_next",
referencedTable: "users",
referencedColumns: []string{"id"},
updateRule: "no action",
deleteRule: "cascade",
},
}
if !sameMySQLForeignKeys(source, target, "smail", "smail_next") {
t.Fatal("foreign key comparison rejected equivalent internal and external references")
}
}
func TestMySQLForeignKeyComparisonsRejectStructuralDifferences(t *testing.T) {
base := []mysqlForeignKey{{
tableName: "messages",
constraintName: "fk_messages_user",
columns: []string{"user_id"},
referencedSchema: "smail_next",
referencedTable: "users",
referencedColumns: []string{"id"},
updateRule: "NO ACTION",
deleteRule: "CASCADE",
}}
tests := []struct {
name string
mutate func(*mysqlForeignKey)
}{
{
name: "column order",
mutate: func(value *mysqlForeignKey) {
value.columns = []string{"tenant_id"}
},
},
{
name: "referenced table",
mutate: func(value *mysqlForeignKey) {
value.referencedTable = "accounts"
},
},
{
name: "update rule",
mutate: func(value *mysqlForeignKey) {
value.updateRule = "CASCADE"
},
},
{
name: "delete rule",
mutate: func(value *mysqlForeignKey) {
value.deleteRule = "RESTRICT"
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
candidate := base[0]
test.mutate(&candidate)
if sameMySQLForeignKeys(base, []mysqlForeignKey{candidate}, "smail", "smail_next") {
t.Fatalf("foreign key comparison accepted %s difference", test.name)
}
})
}
if sameMySQLForeignKeys(base, nil, "smail", "smail_next") {
t.Fatal("foreign key comparison accepted a missing foreign key")
}
extra := append(append([]mysqlForeignKey(nil), base...), mysqlForeignKey{
tableName: "audit",
constraintName: "fk_audit_user",
columns: []string{"user_id"},
referencedSchema: "smail_next",
referencedTable: "users",
referencedColumns: []string{"id"},
updateRule: "NO ACTION",
deleteRule: "CASCADE",
})
if sameMySQLForeignKeys(base, extra, "smail", "smail_next") {
t.Fatal("foreign key comparison accepted an extra foreign key")
}
}