diff --git a/backend/internal/cli/sqlite_upgrade_cli_test.go b/backend/internal/cli/sqlite_upgrade_cli_test.go index d64b8e2..ebc67e3 100644 --- a/backend/internal/cli/sqlite_upgrade_cli_test.go +++ b/backend/internal/cli/sqlite_upgrade_cli_test.go @@ -241,7 +241,7 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) { } defer db.Close() var version, bindings int - if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 11 { + if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 12 { t.Fatalf("正式应用入口未完成历史升级:%d,%v", version, err) } project, err := repository.NewSQLProjectRepository(db.DB).GetProject(t.Context(), domain.DefaultProjectID) @@ -259,6 +259,8 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) { `DELETE FROM projects WHERE id = 'default'`, `UPDATE domains SET project_id = 'missing' WHERE id = 'domain'`, `UPDATE domain_servers SET server_id = 'missing' WHERE domain_id = 'domain'`, + `INSERT OR REPLACE INTO projects (id, name, description) VALUES ('default', '不应覆盖', '')`, + `INSERT OR REPLACE INTO domains (rowid, id, name, certificate_type, sans_json, status) SELECT rowid, 'replacement', 'replacement.example.test', certificate_type, sans_json, status FROM domains WHERE id = 'domain'`, } { if _, err := db.DB.ExecContext(t.Context(), statement); err == nil { t.Fatalf("恢复后的项目引用保护失效:%s", statement) diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go index b7a276f..eaff615 100644 --- a/backend/internal/database/migrate.go +++ b/backend/internal/database/migrate.go @@ -13,6 +13,7 @@ import ( //go:embed migrations/sqlite/0008_user_groups.sql migrations/mysql/0008_user_groups.sql migrations/postgres/0008_user_groups.sql //go:embed migrations/sqlite/0010_projects.sql migrations/mysql/0010_projects.sql migrations/postgres/0010_projects.sql //go:embed migrations/sqlite/0011_resource_projects.sql migrations/mysql/0011_resource_projects.sql migrations/postgres/0011_resource_projects.sql +//go:embed migrations/sqlite/0012_resource_replacement.sql migrations/mysql/0012_resource_replacement.sql migrations/postgres/0012_resource_replacement.sql var migrationFiles embed.FS func Migrate(ctx context.Context, db *sql.DB, driver string) error { @@ -37,7 +38,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error { return nil } -const latestMigrationVersion = 11 +const latestMigrationVersion = 12 // currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。 func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) { @@ -110,6 +111,12 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) return err } } + if driver == "sqlite" && version == 12 { + if err := installSQLiteReplacementGuards(ctx, tx); err != nil { + _ = tx.Rollback() + return err + } + } migrationVersionInsert := "INSERT INTO schema_migrations (version) VALUES (?)" if driver == "postgres" { migrationVersionInsert = "INSERT INTO schema_migrations (version) VALUES ($1)" @@ -183,6 +190,8 @@ func migrationName(version int) string { return "projects" case 11: return "resource_projects" + case 12: + return "resource_replacement" default: return "unknown" } diff --git a/backend/internal/database/migrations/mysql/0012_resource_replacement.sql b/backend/internal/database/migrations/mysql/0012_resource_replacement.sql new file mode 100644 index 0000000..f465f31 --- /dev/null +++ b/backend/internal/database/migrations/mysql/0012_resource_replacement.sql @@ -0,0 +1,2 @@ +-- 本版修复 SQLite 替换语义,MySQL 保留既有外键,统一登记模式版本。 +SELECT 1; diff --git a/backend/internal/database/migrations/postgres/0012_resource_replacement.sql b/backend/internal/database/migrations/postgres/0012_resource_replacement.sql new file mode 100644 index 0000000..887cd07 --- /dev/null +++ b/backend/internal/database/migrations/postgres/0012_resource_replacement.sql @@ -0,0 +1,2 @@ +-- 本版修复 SQLite 替换语义,PostgreSQL 保留既有外键,统一登记模式版本。 +SELECT 1; diff --git a/backend/internal/database/migrations/sqlite/0012_resource_replacement.sql b/backend/internal/database/migrations/sqlite/0012_resource_replacement.sql new file mode 100644 index 0000000..e66b8ce --- /dev/null +++ b/backend/internal/database/migrations/sqlite/0012_resource_replacement.sql @@ -0,0 +1,2 @@ +-- 引用检查及完整触发器由同一迁移事务中的 Go 代码执行,不拆分触发器主体。 +SELECT 1; diff --git a/backend/internal/database/sqlite_replacement_migration_test.go b/backend/internal/database/sqlite_replacement_migration_test.go new file mode 100644 index 0000000..5c97ba6 --- /dev/null +++ b/backend/internal/database/sqlite_replacement_migration_test.go @@ -0,0 +1,56 @@ +package database + +import "testing" + +func TestSQLiteResourceProjectReplacementMigrationRollback(t *testing.T) { + cases := []struct{ name, setup, repair string }{ + {"version_record", `CREATE TRIGGER fail_replacement_version BEFORE INSERT ON schema_migrations WHEN new.version = 12 BEGIN SELECT RAISE(ABORT, 'test-only'); END`, `DROP TRIGGER fail_replacement_version`}, + {"trigger_collision", `CREATE TRIGGER scert_servers_replace_update_guard BEFORE UPDATE ON servers BEGIN SELECT 1; END`, `DROP TRIGGER scert_servers_replace_update_guard`}, + {"orphan_binding", `INSERT OR REPLACE INTO domains (rowid, id, name, certificate_type, sans_json, status) SELECT rowid, 'replacement', 'replacement.example.test', certificate_type, sans_json, status FROM domains WHERE id = 'domain'`, `UPDATE domains SET id = 'domain' WHERE id = 'replacement'`}, + {"orphan_server", `INSERT OR REPLACE INTO servers (rowid, id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) SELECT rowid, 'replacement', name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json FROM servers WHERE id = 'server'`, `UPDATE servers SET id = 'server' WHERE id = 'replacement'`}, + {"missing_project", `INSERT OR REPLACE INTO projects (rowid, id, name, description) SELECT rowid, 'replacement', name, description FROM projects WHERE id = 'default'`, `UPDATE projects SET id = 'default' WHERE id = 'replacement'`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.DB.SetMaxOpenConns(1) + createSQLiteHistoricalBusiness(t, db.DB, 11) + if _, err := db.DB.ExecContext(t.Context(), "PRAGMA recursive_triggers = 0"); err != nil { + t.Fatal(err) + } + if _, err := db.DB.ExecContext(t.Context(), tc.setup); err != nil { + t.Fatal(err) + } + before := snapshotSQLiteReplacementState(t, db.DB) + schemaQuery := `SELECT type, name, sql FROM sqlite_master ORDER BY name` + schema := snapshotSQLiteUpgradeQuery(t, db.DB, schemaQuery) + if err := Migrate(t.Context(), db.DB, "sqlite"); err == nil { + t.Fatal("资源替换保护迁移未拒绝故障") + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + if actual := snapshotSQLiteUpgradeQuery(t, db.DB, schemaQuery); actual != schema { + t.Fatal("迁移失败未完整回滚原模式") + } + version, err := currentMigrationVersion(t.Context(), db.DB) + if err != nil || version != 11 { + t.Fatalf("失败迁移错误登记版本:%d,%v", version, err) + } + if _, err := db.DB.ExecContext(t.Context(), tc.repair); err != nil { + t.Fatal(err) + } + for round := 0; round < 2; round++ { + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + } + var count int + if err := db.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN ('scert_domains_replace_update_guard', 'scert_domains_replace_insert_guard', 'scert_servers_replace_update_guard', 'scert_servers_replace_insert_guard', 'scert_project_replace_insert_guard', 'scert_project_replace_update_guard', 'scert_project_replace_reference_guard')`).Scan(&count); err != nil || count != 7 { + t.Fatalf("重试未完整建立七项保护:%d,%v", count, err) + } + }) + } +} diff --git a/backend/internal/database/sqlite_resource_replacement.go b/backend/internal/database/sqlite_resource_replacement.go new file mode 100644 index 0000000..ab88a15 --- /dev/null +++ b/backend/internal/database/sqlite_resource_replacement.go @@ -0,0 +1,64 @@ +package database + +import ( + "context" + "database/sql" + "fmt" +) + +// 以新增迁移修复已升级的数据库,不修改或重新执行第十一版迁移。 +func installSQLiteReplacementGuards(ctx context.Context, tx *sql.Tx) error { + for _, resource := range []struct{ table, column string }{{"domains", "domain_id"}, {"servers", "server_id"}} { + invalidBinding := fmt.Sprintf(`EXISTS (SELECT 1 FROM domain_servers b LEFT JOIN %s r + ON r.id = b.%s AND r.project_id = b.project_id WHERE r.id IS NULL)`, resource.table, resource.column) + invalidProject := fmt.Sprintf(`EXISTS (SELECT 1 FROM %s r LEFT JOIN projects p + ON p.id = r.project_id WHERE p.id IS NULL)`, resource.table) + var invalid bool + if err := tx.QueryRowContext(ctx, "SELECT "+invalidBinding+" OR "+invalidProject).Scan(&invalid); err != nil { + return fmt.Errorf("检查资源引用失败:%w", err) + } + if invalid { + return fmt.Errorf("%s 存在无效项目或部署引用,拒绝升级,请先核查原数据", resource.table) + } + statements := []string{ + // UPDATE OR REPLACE 可能删除冲突的另一行,不能只检查 OLD 所拥有的绑定。 + fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_replace_update_guard BEFORE UPDATE ON %[1]s + WHEN (NEW.id != OLD.id AND EXISTS (SELECT 1 FROM domain_servers WHERE %[2]s = NEW.id)) + OR (NEW.rowid != OLD.rowid AND EXISTS (SELECT 1 FROM %[1]s r + JOIN domain_servers b ON b.%[2]s = r.id WHERE r.rowid = NEW.rowid)) + BEGIN SELECT RAISE(ABORT, '目标资源仍有部署关联,不能替换'); END`, resource.table, resource.column), + // INSERT 前自动行号尚未确定。插入后验证引用,覆盖显式行号替换且不误拦自动行号。 + // 失败回滚整个外层语句,包括 REPLACE 的隐式删除;不依赖递归触发器开关。 + fmt.Sprintf(`CREATE TRIGGER scert_%s_replace_insert_guard AFTER INSERT ON %s + WHEN %s BEGIN SELECT RAISE(ABORT, '替换操作破坏部署关联'); END`, resource.table, resource.table, invalidBinding), + } + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立资源替换保护失败:%w", err) + } + } + } + statements := []string{ + `CREATE TRIGGER scert_project_replace_insert_guard BEFORE INSERT ON projects + WHEN EXISTS (SELECT 1 FROM domains WHERE project_id = NEW.id) + OR EXISTS (SELECT 1 FROM servers WHERE project_id = NEW.id) + BEGIN SELECT RAISE(ABORT, '项目仍被资源引用,不能替换'); END`, + `CREATE TRIGGER scert_project_replace_update_guard BEFORE UPDATE ON projects + WHEN (NEW.id != OLD.id AND (EXISTS (SELECT 1 FROM domains WHERE project_id = NEW.id) + OR EXISTS (SELECT 1 FROM servers WHERE project_id = NEW.id))) + OR (NEW.rowid != OLD.rowid AND EXISTS (SELECT 1 FROM projects p WHERE p.rowid = NEW.rowid + AND (EXISTS (SELECT 1 FROM domains WHERE project_id = p.id) + OR EXISTS (SELECT 1 FROM servers WHERE project_id = p.id)))) + BEGIN SELECT RAISE(ABORT, '目标项目仍被资源引用,不能替换'); END`, + `CREATE TRIGGER scert_project_replace_reference_guard AFTER INSERT ON projects + WHEN EXISTS (SELECT 1 FROM domains r LEFT JOIN projects p ON p.id = r.project_id WHERE p.id IS NULL) + OR EXISTS (SELECT 1 FROM servers r LEFT JOIN projects p ON p.id = r.project_id WHERE p.id IS NULL) + BEGIN SELECT RAISE(ABORT, '替换操作破坏项目引用'); END`, + } + for _, statement := range statements { + if _, err := tx.ExecContext(ctx, statement); err != nil { + return fmt.Errorf("建立项目替换保护失败:%w", err) + } + } + return nil +} diff --git a/backend/internal/database/sqlite_resource_replacement_test.go b/backend/internal/database/sqlite_resource_replacement_test.go new file mode 100644 index 0000000..b443e46 --- /dev/null +++ b/backend/internal/database/sqlite_resource_replacement_test.go @@ -0,0 +1,107 @@ +package database + +import ( + "database/sql" + "fmt" + "testing" +) + +func TestSQLiteResourceProjectReplacementGuards(t *testing.T) { + cases := []struct{ name, statement string }{ + {"domain_id", `UPDATE OR REPLACE domains SET id = 'domain' WHERE id = 'donor-domain'`}, + {"server_id", `UPDATE OR REPLACE servers SET id = 'server' WHERE id = 'donor-server'`}, + {"project_id", `UPDATE OR REPLACE projects SET id = 'default' WHERE id = 'donor-project'`}, + {"project_insert", `INSERT OR REPLACE INTO projects (id, name, description) VALUES ('default', '替换项目', '')`}, + {"domain_rowid", `UPDATE OR REPLACE domains SET rowid = (SELECT rowid FROM domains WHERE id = 'domain') WHERE id = 'donor-domain'`}, + {"server_rowid", `UPDATE OR REPLACE servers SET _rowid_ = (SELECT rowid FROM servers WHERE id = 'server') WHERE id = 'donor-server'`}, + {"project_rowid", `UPDATE OR REPLACE projects SET oid = (SELECT rowid FROM projects WHERE id = 'default') WHERE id = 'donor-project'`}, + {"domain_insert_rowid", `INSERT OR REPLACE INTO domains (rowid, id, name, certificate_type, sans_json, status) SELECT rowid, 'replacement-domain', 'replacement.example.test', certificate_type, sans_json, status FROM domains WHERE id = 'domain'`}, + {"server_insert_rowid", `INSERT OR REPLACE INTO servers (rowid, id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) SELECT rowid, 'replacement-server', name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json FROM servers WHERE id = 'server'`}, + {"project_insert_rowid", `INSERT OR REPLACE INTO projects (rowid, id, name, description) SELECT rowid, 'replacement-project', name, description FROM projects WHERE id = 'default'`}, + } + for _, foreignKeys := range []int{0, 1} { + for _, recursive := range []int{0, 1} { + for _, tc := range cases { + t.Run(fmt.Sprintf("fk_%d_recursive_%d/%s", foreignKeys, recursive, tc.name), func(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.DB.SetMaxOpenConns(1) + createSQLiteHistoricalBusiness(t, db.DB, 11) + for _, statement := range []string{ + fmt.Sprintf("PRAGMA foreign_keys = %d", foreignKeys), + fmt.Sprintf("PRAGMA recursive_triggers = %d", recursive), + `INSERT INTO projects (id, name, description) VALUES ('donor-project', '无资源项目', '')`, + `INSERT INTO domains (id, name, certificate_type, sans_json, status) VALUES ('donor-domain', 'donor.example.test', 'single', '[]', 'pending')`, + `INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) SELECT 'donor-server', name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json FROM servers WHERE id = 'server'`, + } { + if _, err := db.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatal(err) + } + } + before := snapshotSQLiteReplacementState(t, db.DB) + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + if _, err := db.DB.ExecContext(t.Context(), tc.statement); err == nil { + t.Fatal("替换操作绕过了资源引用保护") + } + assertSQLiteHistoricalBusiness(t, db.DB, before) + for pragma, want := range map[string]int{"foreign_keys": foreignKeys, "recursive_triggers": recursive} { + var actual int + if err := db.DB.QueryRowContext(t.Context(), "PRAGMA "+pragma).Scan(&actual); err != nil || actual != want { + t.Fatalf("替换保护改变连接配置 %s:%d,%v", pragma, actual, err) + } + } + }) + } + } + } +} + +func snapshotSQLiteReplacementState(t *testing.T, db *sql.DB) map[string]sqliteHistoricalTable { + t.Helper() + result := snapshotSQLiteHistoricalBusiness(t, db) + for _, table := range []string{"projects", "domains", "servers"} { + query := "SELECT rowid, * FROM " + table + " ORDER BY id" + result[table+"_with_rowid"] = sqliteHistoricalTable{query: query, rows: snapshotSQLiteUpgradeQuery(t, db, query)} + } + return result +} + +func TestSQLiteResourceProjectReplacementAllowsValidWrites(t *testing.T) { + db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t)) + if err != nil { + t.Fatal(err) + } + defer db.Close() + db.DB.SetMaxOpenConns(1) + createSQLiteHistoricalBusiness(t, db.DB, 11) + if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `UPDATE domains SET name = 'edited.example.test' WHERE id = 'domain'`, + `UPDATE servers SET name = '编辑服务器' WHERE id = 'server'`, + `UPDATE projects SET name = '编辑项目' WHERE id = 'default'`, + `UPDATE domains SET rowid = -1 WHERE id = 'domain'`, + `UPDATE servers SET _rowid_ = -1 WHERE id = 'server'`, + `UPDATE projects SET oid = -1 WHERE id = 'default'`, + `INSERT INTO projects (id, name, description) VALUES ('unreferenced', '无引用项目', '')`, + `INSERT OR REPLACE INTO projects (id, name, description) VALUES ('unreferenced', '允许替换', '')`, + `INSERT INTO domains (id, name, certificate_type, sans_json, status) VALUES ('new-domain', 'new.example.test', 'single', '[]', 'pending')`, + `INSERT OR REPLACE INTO domains (id, name, certificate_type, sans_json, status) VALUES ('new-domain', 'new.example.test', 'single', '[]', 'pending')`, + `INSERT INTO servers (id, name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json) SELECT 'new-server', name, address, port, username, private_key_ref, host_key_fingerprint, certificate_path, private_key_path, certificate_chain_path, web_server, reload_command_json FROM servers WHERE id = 'server'`, + } { + if _, err := db.DB.ExecContext(t.Context(), statement); err != nil { + t.Fatalf("正常写入被替换保护误拦:%s,%v", statement, err) + } + } + var count int + if err := db.DB.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM domain_servers b JOIN domains d ON d.id = b.domain_id AND d.project_id = b.project_id JOIN servers s ON s.id = b.server_id AND s.project_id = b.project_id`).Scan(&count); err != nil || count != 1 { + t.Fatalf("正常写入破坏原有绑定:%d,%v", count, err) + } +}