修复SQLite替换操作绕过资源引用保护并补充回归测试
This commit is contained in:
parent
cad238a5e8
commit
92c844fb23
@ -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)
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- 本版修复 SQLite 替换语义,MySQL 保留既有外键,统一登记模式版本。
|
||||
SELECT 1;
|
||||
@ -0,0 +1,2 @@
|
||||
-- 本版修复 SQLite 替换语义,PostgreSQL 保留既有外键,统一登记模式版本。
|
||||
SELECT 1;
|
||||
@ -0,0 +1,2 @@
|
||||
-- 引用检查及完整触发器由同一迁移事务中的 Go 代码执行,不拆分触发器主体。
|
||||
SELECT 1;
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
64
backend/internal/database/sqlite_resource_replacement.go
Normal file
64
backend/internal/database/sqlite_resource_replacement.go
Normal 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
|
||||
}
|
||||
107
backend/internal/database/sqlite_resource_replacement_test.go
Normal file
107
backend/internal/database/sqlite_resource_replacement_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user