完善域名服务器项目归属与数据库关联约束
保存资源项目、所有者和操作人,接口从会话记录归属,普通更新保留创建信息。 新增三库第十一版迁移、同项目绑定约束和 SQLite 触发器保护,补充迁移回滚重试、仓储、接口及备份恢复测试。 本次整理验证:后端构建、静态检查、关联改造针对性测试和 CLI 包测试通过。 范围说明:项目成员权限及其他资源归属尚未完成;本次未重新执行外部数据库实库、独立程序及生产部署验收。
This commit is contained in:
parent
059e6d7b67
commit
7284958819
@ -864,6 +864,8 @@ func newDomainEditCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
item.ID = existing.ID
|
||||
item.ResourceScope = existing.ResourceScope
|
||||
item.UpdatedBy = ""
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
item.Status = "pending"
|
||||
item.ExpiresAt = nil
|
||||
|
||||
@ -92,6 +92,8 @@ func newServerEditCommand() *cobra.Command {
|
||||
}
|
||||
item := value.server()
|
||||
item.ID = existing.ID
|
||||
item.ResourceScope = existing.ResourceScope
|
||||
item.UpdatedBy = ""
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
item.UpdatedAt = time.Now().UTC()
|
||||
if _, err := service.SSHKeys.ValidateReference(cmd.Context(), item.PrivateKeyRef); err != nil {
|
||||
|
||||
@ -147,6 +147,29 @@ func createSQLiteProjectForBackup(t *testing.T, fixture sqliteBusinessFixture) {
|
||||
if err := repository.NewSQLProjectRepository(db.DB).CreateProject(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
domains := repository.NewSQLDomainRepository(db.DB)
|
||||
resource, err := domains.GetByID(t.Context(), "domain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resource.ID, resource.Name = "project-domain", "project.example.test"
|
||||
resource.ResourceScope = domain.ResourceScope{ProjectID: item.ID, OwnerID: "owner", CreatedBy: "creator", UpdatedBy: "editor"}
|
||||
if err := domains.Create(t.Context(), resource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
servers := repository.NewSQLServerRepository(db.DB)
|
||||
server, err := servers.GetServerByID(t.Context(), "server")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server.ID = "project-server"
|
||||
server.ResourceScope = resource.ResourceScope
|
||||
if err := servers.CreateServer(t.Context(), server); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := servers.ReplaceDomainServers(t.Context(), resource.ID, []string{server.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func runSQLiteProductCLI(t *testing.T, binary string, fixture sqliteBusinessFixture, args ...string) {
|
||||
@ -218,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 != 10 {
|
||||
if err := db.DB.QueryRowContext(t.Context(), "SELECT MAX(version) FROM schema_migrations").Scan(&version); err != nil || version != 11 {
|
||||
t.Fatalf("正式应用入口未完成历史升级:%d,%v", version, err)
|
||||
}
|
||||
project, err := repository.NewSQLProjectRepository(db.DB).GetProject(t.Context(), domain.DefaultProjectID)
|
||||
@ -232,6 +255,15 @@ func assertSQLiteCLIUpgradeUsable(t *testing.T, fixture sqliteBusinessFixture) {
|
||||
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM domain_servers WHERE domain_id = 'domain' AND server_id = 'server'").Scan(&bindings); err != nil || bindings != 1 {
|
||||
t.Fatalf("历史部署绑定丢失:%v", err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`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'`,
|
||||
} {
|
||||
if _, err := db.DB.ExecContext(t.Context(), statement); err == nil {
|
||||
t.Fatalf("恢复后的项目引用保护失效:%s", statement)
|
||||
}
|
||||
}
|
||||
user, err := repository.NewSQLUserRepository(db.DB).FindByIdentifier(t.Context(), "upgrade-owner")
|
||||
if err != nil || bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(fixture.password)) != nil {
|
||||
t.Fatalf("升级后用户密码不可用:%v", err)
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
//go:embed migrations/sqlite/0001_initial.sql migrations/sqlite/0002_domain_options.sql migrations/sqlite/0003_deployment.sql migrations/sqlite/0004_task_attempts.sql migrations/sqlite/0005_users.sql migrations/sqlite/0006_server_validation.sql migrations/sqlite/0007_server_password_auth.sql migrations/sqlite/0009_server_ssh_key_status.sql migrations/mysql/0001_initial.sql migrations/mysql/0002_domain_options.sql migrations/mysql/0003_deployment.sql migrations/mysql/0004_task_attempts.sql migrations/mysql/0005_users.sql migrations/mysql/0006_server_validation.sql migrations/mysql/0007_server_password_auth.sql migrations/mysql/0009_server_ssh_key_status.sql migrations/postgres/0001_initial.sql migrations/postgres/0002_domain_options.sql migrations/postgres/0003_deployment.sql migrations/postgres/0004_task_attempts.sql migrations/postgres/0005_users.sql migrations/postgres/0006_server_validation.sql migrations/postgres/0007_server_password_auth.sql migrations/postgres/0009_server_ssh_key_status.sql
|
||||
//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
|
||||
var migrationFiles embed.FS
|
||||
|
||||
func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
||||
@ -36,7 +37,7 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const latestMigrationVersion = 10
|
||||
const latestMigrationVersion = 11
|
||||
|
||||
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
||||
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||
@ -103,6 +104,12 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
|
||||
return fmt.Errorf("执行数据库迁移失败:%w", err)
|
||||
}
|
||||
}
|
||||
if driver == "sqlite" && version == 11 {
|
||||
if err := installSQLiteResourceConstraints(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)"
|
||||
@ -118,14 +125,21 @@ func applyMigration(ctx context.Context, db *sql.DB, driver string, version int)
|
||||
}
|
||||
|
||||
var (
|
||||
mysqlAddColumnPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+COLUMN\s+` + "`?([a-zA-Z0-9_]+)`?")
|
||||
mysqlCreateIndexPattern = regexp.MustCompile(`(?is)^CREATE\s+INDEX\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ON\s+` + "`?([a-zA-Z0-9_]+)`?")
|
||||
mysqlAddColumnPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+COLUMN\s+` + "`?([a-zA-Z0-9_]+)`?")
|
||||
mysqlCreateIndexPattern = regexp.MustCompile(`(?is)^CREATE\s+(?:UNIQUE\s+)?INDEX\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ON\s+` + "`?([a-zA-Z0-9_]+)`?")
|
||||
mysqlAddConstraintPattern = regexp.MustCompile(`(?is)^ALTER\s+TABLE\s+` + "`?([a-zA-Z0-9_]+)`?" + `\s+ADD\s+CONSTRAINT\s+` + "`?([a-zA-Z0-9_]+)`?")
|
||||
)
|
||||
|
||||
// shouldSkipMySQLStatement 检查可恢复迁移中的字段和索引是否已经存在。
|
||||
// MySQL 5.7 不支持 ADD COLUMN IF NOT EXISTS,因此需要在执行前查询元数据。
|
||||
func shouldSkipMySQLStatement(ctx context.Context, db *sql.DB, statement string) (bool, error) {
|
||||
statement = strings.TrimSpace(statement)
|
||||
if matches := mysqlAddConstraintPattern.FindStringSubmatch(statement); len(matches) == 3 {
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.table_constraints
|
||||
WHERE constraint_schema = DATABASE() AND table_name = ? AND constraint_name = ?`, matches[1], matches[2]).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
if matches := mysqlAddColumnPattern.FindStringSubmatch(statement); len(matches) == 3 {
|
||||
var count int
|
||||
err := db.QueryRowContext(ctx, `
|
||||
@ -167,6 +181,8 @@ func migrationName(version int) string {
|
||||
return "server_ssh_key_status"
|
||||
case 10:
|
||||
return "projects"
|
||||
case 11:
|
||||
return "resource_projects"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
ALTER TABLE domains ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE domains ADD COLUMN owner_id VARCHAR(36) NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN created_by VARCHAR(36) NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN updated_by VARCHAR(36) NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE servers ADD COLUMN owner_id VARCHAR(36) NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN created_by VARCHAR(36) NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN updated_by VARCHAR(36) NOT NULL DEFAULT '';
|
||||
ALTER TABLE domain_servers ADD COLUMN project_id VARCHAR(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT 'default';
|
||||
CREATE UNIQUE INDEX idx_domains_project_id ON domains(project_id, id);
|
||||
CREATE UNIQUE INDEX idx_servers_project_id ON servers(project_id, id);
|
||||
CREATE INDEX idx_domain_servers_project ON domain_servers(project_id);
|
||||
ALTER TABLE domains ADD CONSTRAINT fk_domains_project FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
ALTER TABLE servers ADD CONSTRAINT fk_servers_project FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_domain_project FOREIGN KEY (project_id, domain_id) REFERENCES domains(project_id, id);
|
||||
ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_server_project FOREIGN KEY (project_id, server_id) REFERENCES servers(project_id, id);
|
||||
@ -0,0 +1,16 @@
|
||||
ALTER TABLE domains ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE domains ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN created_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN updated_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE servers ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN created_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN updated_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domain_servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
CREATE UNIQUE INDEX idx_domains_project_id ON domains(project_id, id);
|
||||
CREATE UNIQUE INDEX idx_servers_project_id ON servers(project_id, id);
|
||||
CREATE INDEX idx_domain_servers_project ON domain_servers(project_id);
|
||||
ALTER TABLE domains ADD CONSTRAINT fk_domains_project FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
ALTER TABLE servers ADD CONSTRAINT fk_servers_project FOREIGN KEY (project_id) REFERENCES projects(id);
|
||||
ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_domain_project FOREIGN KEY (project_id, domain_id) REFERENCES domains(project_id, id);
|
||||
ALTER TABLE domain_servers ADD CONSTRAINT fk_binding_server_project FOREIGN KEY (project_id, server_id) REFERENCES servers(project_id, id);
|
||||
@ -0,0 +1,12 @@
|
||||
ALTER TABLE domains ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE domains ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN created_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domains ADD COLUMN updated_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
ALTER TABLE servers ADD COLUMN owner_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN created_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE servers ADD COLUMN updated_by TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE domain_servers ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
|
||||
CREATE UNIQUE INDEX idx_domains_project_id ON domains(project_id, id);
|
||||
CREATE UNIQUE INDEX idx_servers_project_id ON servers(project_id, id);
|
||||
CREATE INDEX idx_domain_servers_project ON domain_servers(project_id);
|
||||
@ -12,7 +12,7 @@ func TestSQLiteProjectMigrationPreservesBusiness(t *testing.T) {
|
||||
defer db.Close()
|
||||
createSQLiteHistoricalBusiness(t, db.DB, 9)
|
||||
before := snapshotSQLiteHistoricalBusiness(t, db.DB)
|
||||
if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil {
|
||||
if err := applyMigration(t.Context(), db.DB, "sqlite", 10); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertSQLiteHistoricalBusiness(t, db.DB, before)
|
||||
|
||||
119
backend/internal/database/resource_project_migration_test.go
Normal file
119
backend/internal/database/resource_project_migration_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteResourceProjectMigration(t *testing.T) {
|
||||
for _, foreignKeys := range []int{0, 1} {
|
||||
t.Run(fmt.Sprintf("foreign_keys_%d", foreignKeys), 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, 10)
|
||||
for _, query := range []string{
|
||||
fmt.Sprintf("PRAGMA foreign_keys = %d", foreignKeys),
|
||||
`CREATE VIEW resource_names AS SELECT id, name FROM domains`,
|
||||
`CREATE TABLE resource_events (name TEXT)`,
|
||||
`CREATE TRIGGER resource_name_event AFTER UPDATE OF name ON domains BEGIN INSERT INTO resource_events VALUES (new.name); END`,
|
||||
`CREATE INDEX custom_resource_name ON domains(name, id)`,
|
||||
} {
|
||||
if _, err := db.DB.ExecContext(t.Context(), query); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before := snapshotSQLiteHistoricalBusiness(t, db.DB)
|
||||
objectsQuery := `SELECT type, name, sql FROM sqlite_master WHERE name IN ('resource_names', 'resource_events', 'resource_name_event', 'custom_resource_name') ORDER BY name`
|
||||
objects := snapshotSQLiteUpgradeQuery(t, db.DB, objectsQuery)
|
||||
for round := 0; round < 2; round++ {
|
||||
if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
assertSQLiteHistoricalBusiness(t, db.DB, before)
|
||||
if actual := snapshotSQLiteUpgradeQuery(t, db.DB, objectsQuery); actual != objects {
|
||||
t.Fatal("资源升级改写已有视图、索引或触发器")
|
||||
}
|
||||
for _, table := range []string{"domains", "servers"} {
|
||||
var project, owner, creator, updater string
|
||||
if err := db.DB.QueryRowContext(t.Context(), "SELECT project_id, owner_id, created_by, updated_by FROM "+table).Scan(&project, &owner, &creator, &updater); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if project != "default" || owner != "" || creator != "" || updater != "" {
|
||||
t.Fatal("历史归属回填不正确,不能编造历史操作者")
|
||||
}
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`UPDATE domains SET project_id = 'missing'`,
|
||||
`DELETE FROM projects WHERE id = 'default'`,
|
||||
`UPDATE domain_servers SET server_id = 'missing'`,
|
||||
`INSERT OR REPLACE INTO domains (id, name, certificate_type, sans_json, status) SELECT 'replacement', name, certificate_type, sans_json, status FROM domains WHERE id = 'domain'`,
|
||||
`INSERT OR REPLACE INTO domains (id, name, certificate_type, sans_json, status) SELECT id, name, certificate_type, sans_json, status FROM domains WHERE id = 'domain'`,
|
||||
`INSERT OR REPLACE 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 id, 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`,
|
||||
} {
|
||||
if _, err := db.DB.ExecContext(t.Context(), statement); err == nil {
|
||||
t.Errorf("资源约束被绕过:%s", statement)
|
||||
}
|
||||
}
|
||||
assertSQLiteHistoricalBusiness(t, db.DB, before)
|
||||
var enabled int
|
||||
if err := db.DB.QueryRowContext(t.Context(), "PRAGMA foreign_keys").Scan(&enabled); err != nil || enabled != foreignKeys {
|
||||
t.Fatalf("迁移改变原连接外键配置:%d,%v", enabled, err)
|
||||
}
|
||||
if _, err := db.DB.ExecContext(t.Context(), `UPDATE domains SET name = 'after.example.test' WHERE id = 'domain'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM resource_events").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("已有触发器升级后未执行:%d,%v", count, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteResourceProjectMigrationRollback(t *testing.T) {
|
||||
for _, failure := range []struct{ name, setup, repair string }{
|
||||
{"version", `CREATE TRIGGER resource_version_failure BEFORE INSERT ON schema_migrations WHEN new.version = 11 BEGIN SELECT RAISE(ABORT, 'test-only'); END`, `DROP TRIGGER resource_version_failure`},
|
||||
{"orphan_domain", `INSERT INTO domain_servers (domain_id, server_id) VALUES ('missing', 'server')`, `DELETE FROM domain_servers WHERE domain_id = 'missing'`},
|
||||
{"orphan_server", `INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'missing')`, `DELETE FROM domain_servers WHERE server_id = 'missing'`},
|
||||
{"missing_project", `DELETE FROM projects WHERE id = 'default'`, `INSERT INTO projects (id, name, description) VALUES ('default', '默认项目', '')`},
|
||||
} {
|
||||
t.Run(failure.name, func(t *testing.T) {
|
||||
db, err := Open(t.Context(), sqliteBusinessUpgradeConfig(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
createSQLiteHistoricalBusiness(t, db.DB, 10)
|
||||
if _, err := db.DB.ExecContext(t.Context(), failure.setup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := snapshotSQLiteHistoricalBusiness(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 != 10 {
|
||||
t.Fatalf("资源迁移失败仍登记版本:%d,%v", version, err)
|
||||
}
|
||||
if _, err := db.DB.ExecContext(t.Context(), failure.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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
92
backend/internal/database/resource_project_mysql_test.go
Normal file
92
backend/internal/database/resource_project_mysql_test.go
Normal file
@ -0,0 +1,92 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMySQLRealResourceProjectMigrationRetry(t *testing.T) {
|
||||
fixture := startRealMySQL(t)
|
||||
db := fixture.database(t)
|
||||
ctx := t.Context()
|
||||
if _, err := db.DB.ExecContext(ctx, migrationTableSQL("mysql")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for version := 1; version <= 10; version++ {
|
||||
if err := applyMigration(ctx, db.DB, "mysql", version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`ALTER TABLE domains CONVERT TO CHARACTER SET latin1 COLLATE latin1_bin`,
|
||||
`ALTER TABLE servers CONVERT TO CHARACTER SET latin1 COLLATE latin1_bin`,
|
||||
`ALTER TABLE domain_servers CONVERT TO CHARACTER SET latin1 COLLATE latin1_bin`,
|
||||
`INSERT INTO domains (id, name, certificate_type, sans_json, status) VALUES ('domain', '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, validation_command_json, ssh_key_error) VALUES ('server', 'server', '192.0.2.10', 22, 'deploy', 'secret://test', 'test-only', '/test/cert.pem', '/test/key.pem', '/test/chain.pem', 'nginx', '[]', '[]', '')`,
|
||||
`INSERT INTO domain_servers (domain_id, server_id) VALUES ('domain', 'server')`,
|
||||
`INSERT INTO domain_servers (domain_id, server_id) VALUES ('missing', 'server')`,
|
||||
} {
|
||||
if _, err := db.DB.ExecContext(ctx, statement); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := Migrate(ctx, db.DB, "mysql"); err == nil {
|
||||
t.Fatal("历史悬空关联未阻断迁移")
|
||||
}
|
||||
var count int
|
||||
if err := db.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM domain_servers").Scan(&count); err != nil || count != 2 {
|
||||
t.Fatalf("迁移失败擅自删除历史关联:%d,%v", count, err)
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx, "DELETE FROM domain_servers WHERE domain_id = 'missing'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var schema string
|
||||
if err := db.DB.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`"
|
||||
if _, err := fixture.admin.ExecContext(ctx, `CREATE TRIGGER `+schema+`.fail_resource_version BEFORE INSERT ON `+schema+`.schema_migrations
|
||||
FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'test-only'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Migrate(ctx, db.DB, "mysql"); err == nil {
|
||||
t.Fatal("版本登记故障未阻断迁移")
|
||||
}
|
||||
version, err := currentMigrationVersion(ctx, db.DB)
|
||||
if err != nil || version != 10 {
|
||||
t.Fatalf("部分执行的迁移错误登记版本:%d,%v", version, err)
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE domains SET owner_id = 'after-partial', created_by = 'retained', updated_by = 'retained' WHERE id = 'domain'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fixture.admin.ExecContext(ctx, "DROP TRIGGER "+schema+".fail_resource_version"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for round := 0; round < 2; round++ {
|
||||
if err := Migrate(ctx, db.DB, "mysql"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var project, owner, creator, updater string
|
||||
if err := db.DB.QueryRowContext(ctx, "SELECT project_id, owner_id, created_by, updated_by FROM domains WHERE id = 'domain'").Scan(&project, &owner, &creator, &updater); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if project != "default" || owner != "after-partial" || creator != "retained" || updater != "retained" {
|
||||
t.Fatal("重试改写已保存的资源归属")
|
||||
}
|
||||
if err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.referential_constraints WHERE constraint_schema = DATABASE() AND constraint_name IN ('fk_domains_project', 'fk_servers_project', 'fk_binding_domain_project', 'fk_binding_server_project')`).Scan(&count); err != nil || count != 4 {
|
||||
t.Fatalf("项目关联外键未完整建立:%d,%v", count, err)
|
||||
}
|
||||
for _, statement := range []string{
|
||||
`DELETE FROM projects WHERE id = 'default'`,
|
||||
`UPDATE domains SET project_id = 'DEFAULT' WHERE id = 'domain'`,
|
||||
`UPDATE domain_servers SET server_id = 'missing'`,
|
||||
} {
|
||||
if _, err := db.DB.ExecContext(ctx, statement); err == nil {
|
||||
t.Errorf("约束未生效:%s", statement)
|
||||
}
|
||||
}
|
||||
if err := db.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM domain_servers WHERE domain_id = 'domain' AND server_id = 'server' AND project_id = 'default'").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("历史有效绑定丢失:%d,%v", count, err)
|
||||
}
|
||||
}
|
||||
85
backend/internal/database/sqlite_resource_constraints.go
Normal file
85
backend/internal/database/sqlite_resource_constraints.go
Normal file
@ -0,0 +1,85 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SQLite 不重建业务表,以触发器实现引用约束;即使连接关闭外键开关也不能绕过。
|
||||
// 触发器作为完整语句执行,不能用迁移文件的分号拆分器切开触发器主体。
|
||||
func installSQLiteResourceConstraints(ctx context.Context, tx *sql.Tx) error {
|
||||
for _, resource := range []struct{ table, column string }{{"domains", "domain_id"}, {"servers", "server_id"}} {
|
||||
var invalid int
|
||||
if err := tx.QueryRowContext(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s r LEFT JOIN projects p ON p.id = r.project_id WHERE p.id IS NULL`, resource.table)).Scan(&invalid); err != nil {
|
||||
return err
|
||||
}
|
||||
if invalid != 0 {
|
||||
return fmt.Errorf("%s 引用了不存在的项目,拒绝迁移", resource.table)
|
||||
}
|
||||
query := fmt.Sprintf(`SELECT COUNT(*) 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)
|
||||
if err := tx.QueryRowContext(ctx, query).Scan(&invalid); err != nil {
|
||||
return err
|
||||
}
|
||||
if invalid != 0 {
|
||||
return fmt.Errorf("历史域名服务器关联存在无效的 %s 引用,拒绝迁移,请先核查原数据", resource.table)
|
||||
}
|
||||
statements := []string{
|
||||
fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_replace_guard BEFORE INSERT ON %[1]s
|
||||
WHEN EXISTS (SELECT 1 FROM domain_servers WHERE %[2]s = NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, '资源仍有部署关联,不能替换'); END`, resource.table, resource.column),
|
||||
fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_project_insert BEFORE INSERT ON %[1]s
|
||||
WHEN NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id)
|
||||
BEGIN SELECT RAISE(ABORT, '资源项目不存在'); END`, resource.table),
|
||||
fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_project_update BEFORE UPDATE OF project_id, id ON %[1]s
|
||||
WHEN NOT EXISTS (SELECT 1 FROM projects WHERE id = NEW.project_id)
|
||||
OR EXISTS (SELECT 1 FROM domain_servers WHERE %[2]s = OLD.id
|
||||
AND (project_id != NEW.project_id OR %[2]s != NEW.id))
|
||||
BEGIN SELECT RAISE(ABORT, '资源项目不存在或仍有部署关联'); END`, resource.table, resource.column),
|
||||
fmt.Sprintf(`CREATE TRIGGER scert_%[1]s_binding_delete BEFORE DELETE ON %[1]s
|
||||
WHEN EXISTS (SELECT 1 FROM domain_servers WHERE %[2]s = OLD.id)
|
||||
BEGIN SELECT RAISE(ABORT, '资源仍有部署关联'); END`, resource.table, resource.column),
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("建立资源项目约束失败:%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// REPLACE 的隐式删除在关闭 recursive_triggers 时不触发删除保护,需保护唯一域名冲突。
|
||||
for _, event := range []string{"INSERT", "UPDATE"} {
|
||||
statement := fmt.Sprintf(`CREATE TRIGGER scert_domain_name_replace_%s BEFORE %s ON domains
|
||||
WHEN EXISTS (SELECT 1 FROM domains d JOIN domain_servers b ON b.domain_id = d.id
|
||||
WHERE d.name = NEW.name AND d.id != NEW.id)
|
||||
BEGIN SELECT RAISE(ABORT, '同名域名仍有部署关联,不能替换'); END`, event, event)
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("建立域名替换保护失败:%w", err)
|
||||
}
|
||||
}
|
||||
for _, event := range []string{"INSERT", "UPDATE"} {
|
||||
statement := fmt.Sprintf(`CREATE TRIGGER scert_binding_project_%s BEFORE %s ON domain_servers
|
||||
WHEN NOT EXISTS (SELECT 1 FROM domains WHERE id = NEW.domain_id AND project_id = NEW.project_id)
|
||||
OR NOT EXISTS (SELECT 1 FROM servers WHERE id = NEW.server_id AND project_id = NEW.project_id)
|
||||
BEGIN SELECT RAISE(ABORT, '域名与服务器必须存在且属于同一项目'); END`, event, event)
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("建立部署关联约束失败:%w", err)
|
||||
}
|
||||
}
|
||||
for _, event := range []string{"DELETE", "UPDATE OF id"} {
|
||||
name := "delete"
|
||||
condition := ""
|
||||
if event != "DELETE" {
|
||||
name = "update"
|
||||
condition = "OLD.id != NEW.id AND "
|
||||
}
|
||||
statement := fmt.Sprintf(`CREATE TRIGGER scert_project_resource_%s BEFORE %s ON projects
|
||||
WHEN %s(EXISTS (SELECT 1 FROM domains WHERE project_id = OLD.id)
|
||||
OR EXISTS (SELECT 1 FROM servers WHERE project_id = OLD.id))
|
||||
BEGIN SELECT RAISE(ABORT, '项目仍被资源引用'); END`, name, event, condition)
|
||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||
return fmt.Errorf("建立项目引用保护失败:%w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -8,6 +8,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
type WebServer string
|
||||
@ -45,6 +47,7 @@ const (
|
||||
var serverPathPlaceholderPattern = regexp.MustCompile(`\{[^{}]+\}`)
|
||||
|
||||
type Server struct {
|
||||
domain.ResourceScope
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
@ -71,6 +74,7 @@ type Server struct {
|
||||
}
|
||||
|
||||
func (s Server) Normalize() Server {
|
||||
s.ResourceScope = s.ResourceScope.Normalize()
|
||||
s.ID = strings.TrimSpace(s.ID)
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
s.Address = strings.TrimSpace(s.Address)
|
||||
|
||||
@ -24,6 +24,7 @@ const (
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
ResourceScope
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CertificateType CertificateType `json:"certificate_type"`
|
||||
@ -78,6 +79,7 @@ func NewDomainWithOptions(name string, certificateType CertificateType, sans []s
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
return Domain{
|
||||
ResourceScope: ResourceScope{ProjectID: DefaultProjectID},
|
||||
ID: NewID(),
|
||||
Name: name,
|
||||
CertificateType: certificateType,
|
||||
|
||||
16
backend/internal/domain/resource_scope.go
Normal file
16
backend/internal/domain/resource_scope.go
Normal file
@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
// ResourceScope 记录资源归属,不代替项目成员授权。空操作者表示历史数据或本地系统操作。
|
||||
type ResourceScope struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
UpdatedBy string `json:"updated_by"`
|
||||
}
|
||||
|
||||
func (s ResourceScope) Normalize() ResourceScope {
|
||||
if s.ProjectID == "" {
|
||||
s.ProjectID = DefaultProjectID
|
||||
}
|
||||
return s
|
||||
}
|
||||
25
backend/internal/httpapi/resource_scope.go
Normal file
25
backend/internal/httpapi/resource_scope.go
Normal file
@ -0,0 +1,25 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 归属由服务端会话填写,不能从请求体接受操作者或项目授权声明。
|
||||
func (s *Server) newResourceScope(c *gin.Context) domain.ResourceScope {
|
||||
scope := domain.ResourceScope{ProjectID: domain.DefaultProjectID}
|
||||
if session, ok := s.currentAuthSession(c); ok {
|
||||
scope.OwnerID = session.UserID
|
||||
scope.CreatedBy = session.UserID
|
||||
scope.UpdatedBy = session.UserID
|
||||
}
|
||||
return scope
|
||||
}
|
||||
|
||||
func (s *Server) updatedResourceScope(c *gin.Context, existing domain.ResourceScope) domain.ResourceScope {
|
||||
existing.UpdatedBy = ""
|
||||
if session, ok := s.currentAuthSession(c); ok {
|
||||
existing.UpdatedBy = session.UserID
|
||||
}
|
||||
return existing.Normalize()
|
||||
}
|
||||
78
backend/internal/httpapi/resource_scope_test.go
Normal file
78
backend/internal/httpapi/resource_scope_test.go
Normal file
@ -0,0 +1,78 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestDomainResourceScopeAPI(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
db, err := database.Open(t.Context(), cfg.Database)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := database.Migrate(t.Context(), db.DB, "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repo := repository.NewSQLDomainRepository(db.DB)
|
||||
server := New(Dependencies{Config: cfg, Repository: repo})
|
||||
request := func(method, path, body, user string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(httpUserSessionCookie(server, user, repository.UserRoleAdmin))
|
||||
response := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(response, req)
|
||||
return response
|
||||
}
|
||||
for _, field := range []string{"project_id", "owner_id", "created_by", "updated_by"} {
|
||||
body := fmt.Sprintf(`{"name":"forged.example.test","certificate_type":"single","%s":"forged"}`, field)
|
||||
response := request(http.MethodPost, "/api/v1/domains", body, "creator")
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("客户端不应提供 %s:%d", field, response.Code)
|
||||
}
|
||||
}
|
||||
response := request(http.MethodPost, "/api/v1/domains", `{"name":"scope.example.test","certificate_type":"single"}`, "creator")
|
||||
if response.Code != http.StatusCreated {
|
||||
t.Fatalf("创建失败:%d,%s", response.Code, response.Body.String())
|
||||
}
|
||||
var created domain.Domain
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := domain.ResourceScope{ProjectID: "default", OwnerID: "creator", CreatedBy: "creator", UpdatedBy: "creator"}
|
||||
if created.ResourceScope != want {
|
||||
t.Fatalf("创建响应归属错误:%+v", created.ResourceScope)
|
||||
}
|
||||
response = request(http.MethodPut, "/api/v1/domains/"+created.ID, `{"name":"edited.example.test","certificate_type":"single"}`, "editor")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("更新失败:%d,%s", response.Code, response.Body.String())
|
||||
}
|
||||
loaded, err := repo.GetByID(t.Context(), created.ID)
|
||||
want.UpdatedBy = "editor"
|
||||
if err != nil || loaded.ResourceScope != want {
|
||||
t.Fatalf("更新后归属错误:%+v,%v", loaded.ResourceScope, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResourceScopePreservesProjectAndCreator(t *testing.T) {
|
||||
server := New(Dependencies{Config: readyTestConfig(t.TempDir())})
|
||||
recorder := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(recorder)
|
||||
ctx.Request = httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
ctx.Request.AddCookie(httpUserSessionCookie(server, "editor", repository.UserRoleAdmin))
|
||||
existing := domain.ResourceScope{ProjectID: "project-a", OwnerID: "owner", CreatedBy: "creator", UpdatedBy: "previous"}
|
||||
updated := server.updatedResourceScope(ctx, existing)
|
||||
if updated.ProjectID != existing.ProjectID || updated.OwnerID != existing.OwnerID || updated.CreatedBy != existing.CreatedBy || updated.UpdatedBy != "editor" {
|
||||
t.Fatalf("更新操作者时丢失原资源归属:%+v", updated)
|
||||
}
|
||||
}
|
||||
@ -546,6 +546,7 @@ func (s *Server) createDomain(c *gin.Context) {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
item.ResourceScope = s.newResourceScope(c)
|
||||
if err := s.repository.Create(c.Request.Context(), item); err != nil {
|
||||
if repository.IsConflict(err) {
|
||||
s.writeError(c, http.StatusConflict, "域名已经存在", err)
|
||||
@ -585,6 +586,7 @@ func (s *Server) updateDomain(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
item.ID = existing.ID
|
||||
item.ResourceScope = s.updatedResourceScope(c, existing.ResourceScope)
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
item.Status = "pending"
|
||||
item.ExpiresAt = nil
|
||||
@ -702,6 +704,7 @@ func (s *Server) createServer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
item := serverFromRequest(request)
|
||||
item.ResourceScope = s.newResourceScope(c)
|
||||
item = item.Normalize()
|
||||
now := time.Now().UTC()
|
||||
item.CreatedAt = now
|
||||
@ -754,6 +757,7 @@ func (s *Server) updateServer(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
item.ResourceScope = s.updatedResourceScope(c, existing.ResourceScope)
|
||||
item.UpdatedAt = time.Now().UTC()
|
||||
item, cleanup, err := s.prepareServerCredential(c.Request.Context(), item, request.Password, &existing)
|
||||
if err != nil {
|
||||
|
||||
@ -342,6 +342,9 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
|
||||
t.Fatalf("服务器 API 未使用默认远程证书路径模板:%+v", created)
|
||||
}
|
||||
|
||||
if created.ProjectID != "default" || created.OwnerID != "test-admin" || created.CreatedBy != "test-admin" || created.UpdatedBy != "test-admin" {
|
||||
t.Fatalf("服务器未记录服务端会话归属:%+v", created.ResourceScope)
|
||||
}
|
||||
updateRequest := httptest.NewRequest(http.MethodPut, "/api/v1/servers/"+created.ID, strings.NewReader(`{"name":"updated-server","address":"192.0.2.50","port":22,"username":"deploy","private_key_ref":"`+key.Reference+`","host_key_fingerprint":"SHA256:test","web_server":"nginx"}`))
|
||||
updateRequest.Header.Set("Content-Type", "application/json")
|
||||
updateResponse := httptest.NewRecorder()
|
||||
@ -356,6 +359,9 @@ func TestServerAndDomainAssociationAPI(t *testing.T) {
|
||||
if updated.Name != "updated-server" || updated.CreatedAt.IsZero() || updated.UpdatedAt.IsZero() {
|
||||
t.Fatalf("updated server response is incomplete: %+v", updated)
|
||||
}
|
||||
if updated.ResourceScope != created.ResourceScope {
|
||||
t.Fatalf("服务器更新丢失归属:%+v", updated.ResourceScope)
|
||||
}
|
||||
|
||||
associationRequest := httptest.NewRequest(http.MethodPut, "/api/v1/domains/"+item.ID+"/servers", strings.NewReader(`{"server_ids":["`+created.ID+`"]}`))
|
||||
associationRequest.Header.Set("Content-Type", "application/json")
|
||||
|
||||
225
backend/internal/repository/resource_project_test.go
Normal file
225
backend/internal/repository/resource_project_test.go
Normal file
@ -0,0 +1,225 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
|
||||
)
|
||||
|
||||
func TestResourceProjectSQLite(t *testing.T) {
|
||||
cfg := config.Default(t.TempDir()).Database
|
||||
cfg.DSN = filepath.Join(t.TempDir(), "resource-project.db")
|
||||
checkResourceProjects(t, cfg)
|
||||
}
|
||||
|
||||
func TestResourceProjectMySQLReal(t *testing.T) {
|
||||
server := mysqltest.New(t)
|
||||
checkResourceProjects(t, server.Database(t))
|
||||
}
|
||||
|
||||
func checkResourceProjects(t *testing.T, cfg config.DatabaseConfig) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
db, err := database.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := database.Migrate(ctx, db.DB, cfg.Driver); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects := NewSQLProjectRepository(db.DB, cfg.Driver)
|
||||
domains := NewSQLDomainRepository(db.DB, cfg.Driver)
|
||||
servers := NewSQLServerRepository(db.DB, cfg.Driver)
|
||||
for _, id := range []string{"project-a", "project-b"} {
|
||||
if err := projects.CreateProject(ctx, domain.Project{ID: id, Name: id}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
item, err := domain.NewDomain("resource.example.test", domain.CertificateSingle, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := domain.ResourceScope{ProjectID: "project-a", OwnerID: "owner", CreatedBy: "creator", UpdatedBy: "creator"}
|
||||
item.ResourceScope = scope
|
||||
if err := domains.Create(ctx, item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := deployment.Server{
|
||||
ResourceScope: scope,
|
||||
ID: "server-a", Name: "项目服务器", Address: "192.0.2.10", Port: 22, Username: "deploy",
|
||||
PrivateKeyRef: "secret://ssh/test", HostKeyFingerprint: "SHA256:test",
|
||||
CertificatePath: "/test/cert.pem", PrivateKeyPath: "/test/key.pem",
|
||||
CertificateChainPath: "/test/chain.pem", WebServer: deployment.WebServerNginx,
|
||||
}
|
||||
if err := servers.CreateServer(ctx, server); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := server
|
||||
other.ID = "server-b"
|
||||
other.ProjectID = "project-b"
|
||||
if err := servers.CreateServer(ctx, other); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("persisted_scope", func(t *testing.T) {
|
||||
loaded, err := domains.GetByID(ctx, item.ID)
|
||||
if err != nil || loaded.ResourceScope != scope {
|
||||
t.Fatalf("域名归属未持久化:%+v,%v", loaded.ResourceScope, err)
|
||||
}
|
||||
loadedServer, err := servers.GetServerByID(ctx, server.ID)
|
||||
if err != nil || loadedServer.ResourceScope != scope {
|
||||
t.Fatalf("服务器归属未持久化:%+v,%v", loadedServer.ResourceScope, err)
|
||||
}
|
||||
items, err := domains.List(ctx)
|
||||
if err != nil || len(items) != 1 || items[0].ResourceScope != scope {
|
||||
t.Fatalf("域名列表丢失归属:%v", err)
|
||||
}
|
||||
serverItems, err := servers.ListServers(ctx)
|
||||
if err != nil || len(serverItems) != 2 {
|
||||
t.Fatalf("服务器列表错误:%v", err)
|
||||
}
|
||||
for _, value := range serverItems {
|
||||
if value.OwnerID != scope.OwnerID || value.CreatedBy != scope.CreatedBy || value.ProjectID == "" {
|
||||
t.Fatal("服务器列表丢失归属")
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("update_preserves_identity", func(t *testing.T) {
|
||||
changed := item
|
||||
changed.OwnerID, changed.CreatedBy, changed.UpdatedBy = "forged-owner", "forged-creator", "editor"
|
||||
changed.Name = "updated.example.test"
|
||||
if err := domains.Update(ctx, changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changedServer := server
|
||||
changedServer.OwnerID, changedServer.CreatedBy, changedServer.UpdatedBy = "forged-owner", "forged-creator", "editor"
|
||||
if err := servers.UpdateServer(ctx, changedServer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 包含 MySQL 实际没有字段变化时的重复更新。
|
||||
if err := domains.Update(ctx, changed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := domains.GetByID(ctx, item.ID)
|
||||
if err != nil || loaded.OwnerID != "owner" || loaded.CreatedBy != "creator" || loaded.UpdatedBy != "editor" {
|
||||
t.Fatalf("域名更新错误覆盖归属:%+v,%v", loaded.ResourceScope, err)
|
||||
}
|
||||
loadedServer, err := servers.GetServerByID(ctx, server.ID)
|
||||
if err != nil || loadedServer.OwnerID != "owner" || loadedServer.CreatedBy != "creator" || loadedServer.UpdatedBy != "editor" {
|
||||
t.Fatalf("服务器更新错误覆盖归属:%+v,%v", loadedServer.ResourceScope, err)
|
||||
}
|
||||
changed.ProjectID = "project-b"
|
||||
changedServer.ProjectID = "project-b"
|
||||
if err := domains.Update(ctx, changed); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("普通更新不应迁移域名项目:%v", err)
|
||||
}
|
||||
if err := servers.UpdateServer(ctx, changedServer); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("普通更新不应迁移服务器项目:%v", err)
|
||||
}
|
||||
})
|
||||
t.Run("project_delete_protected_without_binding", func(t *testing.T) {
|
||||
if err := projects.DeleteProject(ctx, "project-a"); err == nil {
|
||||
t.Fatal("仍被资源引用的项目被删除")
|
||||
}
|
||||
if err := projects.DeleteProject(ctx, "project-b"); err == nil {
|
||||
t.Fatal("仅被服务器引用的项目被删除")
|
||||
}
|
||||
})
|
||||
if err := servers.ReplaceDomainServers(ctx, item.ID, []string{server.ID, server.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertBinding := func(t *testing.T) {
|
||||
t.Helper()
|
||||
items, err := servers.ListServersByDomainID(ctx, item.ID)
|
||||
if err != nil || len(items) != 1 || items[0].ID != server.ID || items[0].ProjectID != item.ProjectID {
|
||||
t.Fatalf("原部署绑定未保留:%+v,%v", items, err)
|
||||
}
|
||||
}
|
||||
for _, ids := range [][]string{{server.ID, other.ID}, {server.ID, "missing"}, {""}} {
|
||||
t.Run("binding_failure_keeps_previous", func(t *testing.T) {
|
||||
if err := servers.ReplaceDomainServers(ctx, item.ID, ids); err == nil {
|
||||
t.Fatal("无效或跨项目绑定未被拒绝")
|
||||
}
|
||||
assertBinding(t)
|
||||
})
|
||||
}
|
||||
t.Run("missing_domain_even_when_clearing", func(t *testing.T) {
|
||||
if err := servers.ReplaceDomainServers(ctx, "missing", nil); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("不存在域名的空绑定应被拒绝:%v", err)
|
||||
}
|
||||
})
|
||||
t.Run("database_constraints", func(t *testing.T) {
|
||||
statements := []struct {
|
||||
query string
|
||||
args []any
|
||||
}{
|
||||
{"UPDATE domains SET project_id = 'missing' WHERE id = ?", []any{item.ID}},
|
||||
{"UPDATE servers SET project_id = 'missing' WHERE id = ?", []any{other.ID}},
|
||||
{"UPDATE domains SET project_id = 'project-b' WHERE id = ?", []any{item.ID}},
|
||||
{"UPDATE servers SET project_id = 'project-b' WHERE id = ?", []any{server.ID}},
|
||||
{"UPDATE domains SET id = 'renamed' WHERE id = ?", []any{item.ID}},
|
||||
{"UPDATE servers SET id = 'renamed' WHERE id = ?", []any{server.ID}},
|
||||
{"UPDATE projects SET id = 'renamed' WHERE id = 'project-a'", nil},
|
||||
{"DELETE FROM projects WHERE id = 'project-a'", nil},
|
||||
{"DELETE FROM domains WHERE id = ?", []any{item.ID}},
|
||||
{"DELETE FROM servers WHERE id = ?", []any{server.ID}},
|
||||
{"INSERT INTO domain_servers (domain_id, server_id, project_id) VALUES (?, ?, 'project-a')", []any{item.ID, other.ID}},
|
||||
{"UPDATE domain_servers SET server_id = ? WHERE domain_id = ?", []any{other.ID, item.ID}},
|
||||
{"UPDATE domain_servers SET project_id = 'project-b' WHERE domain_id = ?", []any{item.ID}},
|
||||
{"INSERT INTO domain_servers (domain_id, server_id) VALUES ('missing', ?)", []any{server.ID}},
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := db.DB.ExecContext(ctx, Rebind(cfg.Driver, statement.query), statement.args...); err == nil {
|
||||
t.Errorf("直接 SQL 绕过约束:%s", statement.query)
|
||||
}
|
||||
}
|
||||
assertBinding(t)
|
||||
})
|
||||
t.Run("missing_project_create", func(t *testing.T) {
|
||||
invalid := item
|
||||
invalid.ID, invalid.Name, invalid.ProjectID = "invalid-domain", "invalid.example.test", "missing"
|
||||
if err := domains.Create(ctx, invalid); err == nil {
|
||||
t.Fatal("域名引用不存在项目")
|
||||
}
|
||||
invalidServer := server
|
||||
invalidServer.ID, invalidServer.ProjectID = "invalid-server", "missing"
|
||||
if err := servers.CreateServer(ctx, invalidServer); err == nil {
|
||||
t.Fatal("服务器引用不存在项目")
|
||||
}
|
||||
})
|
||||
t.Run("legacy_default", func(t *testing.T) {
|
||||
legacy, err := domain.NewDomain("legacy.example.test", domain.CertificateSingle, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy.ResourceScope = domain.ResourceScope{}
|
||||
if err := domains.Create(ctx, legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := domains.GetByID(ctx, legacy.ID)
|
||||
if err != nil || loaded.ProjectID != domain.DefaultProjectID || loaded.OwnerID != "" {
|
||||
t.Fatalf("兼容调用的默认项目错误:%+v,%v", loaded.ResourceScope, err)
|
||||
}
|
||||
})
|
||||
t.Run("delete_resources_then_project", func(t *testing.T) {
|
||||
if err := domains.Delete(ctx, item.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := projects.DeleteProject(ctx, "project-a"); err == nil {
|
||||
t.Fatal("服务器仍引用项目时不应允许删除")
|
||||
}
|
||||
if err := servers.DeleteServer(ctx, server.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := projects.DeleteProject(ctx, "project-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -58,9 +58,10 @@ func (r *SQLServerRepository) CreateServer(ctx context.Context, server deploymen
|
||||
updatedAt = createdAt
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO servers
|
||||
(id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef,
|
||||
server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt)
|
||||
(id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at, project_id, owner_id, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), server.ID, server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef,
|
||||
server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), createdAt, updatedAt,
|
||||
server.ProjectID, server.OwnerID, server.CreatedBy, server.UpdatedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存服务器失败:%w", err)
|
||||
}
|
||||
@ -84,8 +85,8 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = time.Now().UTC()
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, ssh_key_status = ?, ssh_key_fingerprint = ?, ssh_key_deployed_at = ?, ssh_key_tested_at = ?, ssh_key_error = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ? WHERE id = ?`),
|
||||
server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.ID)
|
||||
result, err := r.db.ExecContext(ctx, r.query(`UPDATE servers SET name = ?, address = ?, port = ?, username = ?, auth_method = ?, private_key_ref = ?, password_ref = ?, host_key_fingerprint = ?, ssh_key_status = ?, ssh_key_fingerprint = ?, ssh_key_deployed_at = ?, ssh_key_tested_at = ?, ssh_key_error = ?, certificate_path = ?, private_key_path = ?, certificate_chain_path = ?, web_server = ?, validation_command_json = ?, reload_command_json = ?, updated_at = ?, updated_by = ? WHERE id = ? AND project_id = ?`),
|
||||
server.Name, server.Address, server.Port, server.Username, server.AuthMethod, server.PrivateKeyRef, server.PasswordRef, server.HostKeyFingerprint, server.SSHKeyStatus, server.SSHKeyFingerprint, server.SSHKeyDeployedAt, server.SSHKeyTestedAt, server.SSHKeyError, server.CertificatePath, server.PrivateKeyPath, server.CertificateChainPath, server.WebServer, string(validationCommand), string(command), updatedAt, server.UpdatedBy, server.ID, server.ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新服务器失败:%w", err)
|
||||
}
|
||||
@ -94,7 +95,13 @@ func (r *SQLServerRepository) UpdateServer(ctx context.Context, server deploymen
|
||||
return fmt.Errorf("读取服务器更新结果失败:%w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return sql.ErrNoRows
|
||||
stored, err := r.GetServerByID(ctx, server.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored.ProjectID != server.ProjectID {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -190,6 +197,7 @@ func (r *SQLServerRepository) ListServersByDomainID(ctx context.Context, domainI
|
||||
}
|
||||
|
||||
func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID string, serverIDs []string) error {
|
||||
domainID = strings.TrimSpace(domainID)
|
||||
if strings.TrimSpace(domainID) == "" {
|
||||
return errors.New("域名 ID 不能为空")
|
||||
}
|
||||
@ -214,18 +222,22 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID
|
||||
_ = tx.Rollback()
|
||||
return cause
|
||||
}
|
||||
var projectID string
|
||||
if err := tx.QueryRowContext(ctx, r.query("SELECT project_id FROM domains WHERE id = ?"), domainID).Scan(&projectID); err != nil {
|
||||
return rollback(fmt.Errorf("读取绑定域名项目失败:%w", err))
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.query("DELETE FROM domain_servers WHERE domain_id = ?"), domainID); err != nil {
|
||||
return rollback(fmt.Errorf("清理域名服务器关联失败:%w", err))
|
||||
}
|
||||
for _, serverID := range cleaned {
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, r.query("SELECT COUNT(*) FROM servers WHERE id = ?"), serverID).Scan(&exists); err != nil {
|
||||
if err := tx.QueryRowContext(ctx, r.query("SELECT COUNT(*) FROM servers WHERE id = ? AND project_id = ?"), serverID, projectID).Scan(&exists); err != nil {
|
||||
return rollback(fmt.Errorf("校验部署服务器失败:%w", err))
|
||||
}
|
||||
if exists == 0 {
|
||||
return rollback(fmt.Errorf("部署服务器不存在:%s", serverID))
|
||||
return rollback(fmt.Errorf("部署服务器不存在或不属于域名项目:%s", serverID))
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.query("INSERT INTO domain_servers (domain_id, server_id) VALUES (?, ?)"), domainID, serverID); err != nil {
|
||||
if _, err := tx.ExecContext(ctx, r.query("INSERT INTO domain_servers (domain_id, server_id, project_id) VALUES (?, ?, ?)"), domainID, serverID, projectID); err != nil {
|
||||
return rollback(fmt.Errorf("保存域名服务器关联失败:%w", err))
|
||||
}
|
||||
}
|
||||
@ -235,8 +247,8 @@ func (r *SQLServerRepository) ReplaceDomainServers(ctx context.Context, domainID
|
||||
return nil
|
||||
}
|
||||
|
||||
const serverSelect = `SELECT id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at FROM servers`
|
||||
const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, servers.private_key_ref, servers.password_ref, servers.host_key_fingerprint, servers.ssh_key_status, servers.ssh_key_fingerprint, servers.ssh_key_deployed_at, servers.ssh_key_tested_at, servers.ssh_key_error, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at FROM servers`
|
||||
const serverSelect = `SELECT id, name, address, port, username, auth_method, private_key_ref, password_ref, host_key_fingerprint, ssh_key_status, ssh_key_fingerprint, ssh_key_deployed_at, ssh_key_tested_at, ssh_key_error, certificate_path, private_key_path, certificate_chain_path, web_server, validation_command_json, reload_command_json, created_at, updated_at, project_id, owner_id, created_by, updated_by FROM servers`
|
||||
const serverSelectQualified = `SELECT servers.id, servers.name, servers.address, servers.port, servers.username, servers.auth_method, servers.private_key_ref, servers.password_ref, servers.host_key_fingerprint, servers.ssh_key_status, servers.ssh_key_fingerprint, servers.ssh_key_deployed_at, servers.ssh_key_tested_at, servers.ssh_key_error, servers.certificate_path, servers.private_key_path, servers.certificate_chain_path, servers.web_server, servers.validation_command_json, servers.reload_command_json, servers.created_at, servers.updated_at, servers.project_id, servers.owner_id, servers.created_by, servers.updated_by FROM servers`
|
||||
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
@ -248,7 +260,7 @@ func scanServer(row rowScanner) (deployment.Server, error) {
|
||||
var authMethod string
|
||||
var validationCommandJSON string
|
||||
var commandJSON string
|
||||
if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PrivateKeyRef, &server.PasswordRef, &server.HostKeyFingerprint, &server.SSHKeyStatus, &server.SSHKeyFingerprint, &server.SSHKeyDeployedAt, &server.SSHKeyTestedAt, &server.SSHKeyError, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt); err != nil {
|
||||
if err := row.Scan(&server.ID, &server.Name, &server.Address, &server.Port, &server.Username, &authMethod, &server.PrivateKeyRef, &server.PasswordRef, &server.HostKeyFingerprint, &server.SSHKeyStatus, &server.SSHKeyFingerprint, &server.SSHKeyDeployedAt, &server.SSHKeyTestedAt, &server.SSHKeyError, &server.CertificatePath, &server.PrivateKeyPath, &server.CertificateChainPath, &webServer, &validationCommandJSON, &commandJSON, &server.CreatedAt, &server.UpdatedAt, &server.ProjectID, &server.OwnerID, &server.CreatedBy, &server.UpdatedBy); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return deployment.Server{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import (
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/deployment"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
)
|
||||
|
||||
func TestServerRepositoryCRUDAndDomainAssociations(t *testing.T) {
|
||||
@ -52,6 +53,14 @@ func TestServerRepositoryCRUDAndDomainAssociations(t *testing.T) {
|
||||
if updated.CreatedAt.IsZero() || updated.UpdatedAt.IsZero() || updated.UpdatedAt.Before(updated.CreatedAt) {
|
||||
t.Fatalf("updated server timestamps are invalid: %+v", updated)
|
||||
}
|
||||
item, err := domain.NewDomain("example.test", domain.CertificateSingle, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item.ID = "domain-1"
|
||||
if err := NewSQLDomainRepository(db.DB).Create(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.ReplaceDomainServers(context.Background(), "domain-1", []string{server.ID, server.ID}); err != nil {
|
||||
t.Fatalf("保存域名服务器关联失败:%v", err)
|
||||
}
|
||||
@ -128,6 +137,14 @@ func TestReplaceDomainServersRejectsUnknownServerWithoutChangingAssociations(t *
|
||||
if err := repo.CreateServer(context.Background(), server); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := domain.NewDomain("association.example.test", domain.CertificateSingle, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item.ID = "domain-1"
|
||||
if err := NewSQLDomainRepository(db.DB).Create(t.Context(), item); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.ReplaceDomainServers(context.Background(), "domain-1", []string{server.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@ -34,9 +34,10 @@ func (r *SQLDomainRepository) Create(ctx context.Context, item domain.Domain) er
|
||||
return fmt.Errorf("编码 SAN 域名失败:%w", err)
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx, r.query(`INSERT INTO domains
|
||||
(id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew,
|
||||
item.ACMEEnvironment, item.DNSProvider, item.Status, nullableTime(item.ExpiresAt), item.CreatedAt, item.UpdatedAt)
|
||||
(id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), item.ID, item.Name, item.CertificateType, string(payload), item.AutoRenew,
|
||||
item.ACMEEnvironment, item.DNSProvider, item.Status, nullableTime(item.ExpiresAt), item.CreatedAt, item.UpdatedAt,
|
||||
item.ResourceScope.Normalize().ProjectID, item.OwnerID, item.CreatedBy, item.UpdatedBy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存域名失败:%w", err)
|
||||
}
|
||||
@ -52,9 +53,9 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er
|
||||
return fmt.Errorf("编码 SAN 域名失败:%w", err)
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, r.query(`UPDATE domains SET
|
||||
name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?
|
||||
WHERE id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment,
|
||||
item.DNSProvider, item.UpdatedAt, item.ID)
|
||||
name = ?, certificate_type = ?, sans_json = ?, auto_renew = ?, acme_environment = ?, dns_provider = ?, updated_at = ?, updated_by = ?
|
||||
WHERE id = ? AND project_id = ?`), item.Name, item.CertificateType, string(payload), item.AutoRenew, item.ACMEEnvironment,
|
||||
item.DNSProvider, item.UpdatedAt, item.UpdatedBy, item.ID, item.ResourceScope.Normalize().ProjectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新域名失败:%w", err)
|
||||
}
|
||||
@ -63,7 +64,13 @@ func (r *SQLDomainRepository) Update(ctx context.Context, item domain.Domain) er
|
||||
return fmt.Errorf("读取更新结果失败:%w", err)
|
||||
}
|
||||
if count == 0 {
|
||||
return sql.ErrNoRows
|
||||
stored, err := r.GetByID(ctx, item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if stored.ProjectID != item.ResourceScope.Normalize().ProjectID {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -72,7 +79,7 @@ func (r *SQLDomainRepository) GetByID(ctx context.Context, id string) (domain.Do
|
||||
if r == nil || r.db == nil {
|
||||
return domain.Domain{}, errors.New("数据库连接不能为空")
|
||||
}
|
||||
row := r.db.QueryRowContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at
|
||||
row := r.db.QueryRowContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by
|
||||
FROM domains WHERE id = ?`), id)
|
||||
return scanDomain(row)
|
||||
}
|
||||
@ -81,7 +88,7 @@ func (r *SQLDomainRepository) List(ctx context.Context) ([]domain.Domain, error)
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("数据库连接不能为空")
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at
|
||||
rows, err := r.db.QueryContext(ctx, r.query(`SELECT id, name, certificate_type, sans_json, auto_renew, acme_environment, dns_provider, status, expires_at, created_at, updated_at, project_id, owner_id, created_by, updated_by
|
||||
FROM domains ORDER BY name`))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询域名失败:%w", err)
|
||||
@ -168,7 +175,7 @@ func scanDomain(row scanner) (domain.Domain, error) {
|
||||
var acmeEnvironment string
|
||||
var dnsProvider string
|
||||
var expiresAt sql.NullTime
|
||||
if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt); err != nil {
|
||||
if err := row.Scan(&item.ID, &item.Name, &certificateType, &sansJSON, &autoRenew, &acmeEnvironment, &dnsProvider, &item.Status, &expiresAt, &item.CreatedAt, &item.UpdatedAt, &item.ProjectID, &item.OwnerID, &item.CreatedBy, &item.UpdatedBy); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.Domain{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user