diff --git a/docs/安全审查风险整改任务清单.md b/docs/安全审查风险整改任务清单.md index 29b4ee9..fb1dcf2 100644 --- a/docs/安全审查风险整改任务清单.md +++ b/docs/安全审查风险整改任务清单.md @@ -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(本机自动化复审)。 diff --git a/docs/配置系统重构实施任务清单.md b/docs/配置系统重构实施任务清单.md index 471be9a..fd97cba 100644 --- a/docs/配置系统重构实施任务清单.md +++ b/docs/配置系统重构实施任务清单.md @@ -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(本机自动化复审)。 diff --git a/fe/src/views/components/SecuritySettings/SecuritySettings.vue b/fe/src/views/components/SecuritySettings/SecuritySettings.vue index 1523313..ff1f1c2 100644 --- a/fe/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/fe/src/views/components/SecuritySettings/SecuritySettings.vue @@ -44,7 +44,6 @@ -
diff --git a/server/services/dbmigration/sql_executor.go b/server/services/dbmigration/sql_executor.go index 83b7f26..db66d42 100644 --- a/server/services/dbmigration/sql_executor.go +++ b/server/services/dbmigration/sql_executor.go @@ -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 diff --git a/server/services/dbmigration/sql_executor_test.go b/server/services/dbmigration/sql_executor_test.go index 25e2860..e7feb5a 100644 --- a/server/services/dbmigration/sql_executor_test.go +++ b/server/services/dbmigration/sql_executor_test.go @@ -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") + } +}