Scert/backend/internal/repository/resource_project_test.go
xuans 7284958819 完善域名服务器项目归属与数据库关联约束
保存资源项目、所有者和操作人,接口从会话记录归属,普通更新保留创建信息。
新增三库第十一版迁移、同项目绑定约束和 SQLite 触发器保护,补充迁移回滚重试、仓储、接口及备份恢复测试。
本次整理验证:后端构建、静态检查、关联改造针对性测试和 CLI 包测试通过。
范围说明:项目成员权限及其他资源归属尚未完成;本次未重新执行外部数据库实库、独立程序及生产部署验收。
2026-09-28 10:45:00 +08:00

226 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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