修复SQLite替换操作绕过资源引用保护并补充回归测试

This commit is contained in:
xuans 2026-09-28 11:07:42 +08:00
parent cad238a5e8
commit 92c844fb23
8 changed files with 246 additions and 2 deletions

View File

@ -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)

View File

@ -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"
}

View File

@ -0,0 +1,2 @@
-- 本版修复 SQLite 替换语义,MySQL 保留既有外键,统一登记模式版本。
SELECT 1;

View File

@ -0,0 +1,2 @@
-- 本版修复 SQLite 替换语义,PostgreSQL 保留既有外键,统一登记模式版本。
SELECT 1;

View File

@ -0,0 +1,2 @@
-- 引用检查及完整触发器由同一迁移事务中的 Go 代码执行,不拆分触发器主体。
SELECT 1;

View File

@ -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)
}
})
}
}

View File

@ -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
}

View File

@ -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)
}
}