package repository import ( "context" "database/sql" "errors" "path/filepath" "strings" "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 TestProjectLifecycleSQLite(t *testing.T) { checkProjectLifecycle(t, func(t *testing.T) config.DatabaseConfig { cfg := config.Default(t.TempDir()).Database cfg.DSN = filepath.Join(t.TempDir(), "project-lifecycle.db") return cfg }, nil) } func TestProjectLifecycleMySQLReal(t *testing.T) { server := mysqltest.New(t) checkProjectLifecycle(t, server.Database, server.Admin()) } func checkProjectLifecycle(t *testing.T, makeConfig func(*testing.T) config.DatabaseConfig, admin *sql.DB) { t.Helper() run := func(name string, check func(*testing.T, *sql.DB, *SQLProjectMemberRepository, string)) { t.Run(name, func(t *testing.T) { cfg := makeConfig(t) db, err := database.Open(t.Context(), cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil { t.Fatal(err) } for _, user := range []struct{ id, role, status string }{ {"root", "super_admin", "active"}, {"manager", "user", "active"}, {"global-admin", "admin", "active"}, {"viewer", "user", "active"}, {"disabled", "super_admin", "disabled"}, } { _, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users (id, username, email, password_hash, role, status) VALUES (?, ?, ?, 'test-only-no-login', ?, ?)`), user.id, user.id, user.id+"@example.invalid", user.role, user.status) if err != nil { t.Fatal(err) } } check(t, db.DB, NewSQLProjectMemberRepository(db.DB, cfg.Driver), cfg.Driver) }) } run("create_update_delete_and_history", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { item, err := repo.CreateManagedProject(t.Context(), "root", " 项目一 ", "说明") if err != nil || item.ID == "" || item.ID == domain.DefaultProjectID || item.Name != "项目一" { t.Fatalf("创建失败:%+v,%v", item, err) } projects := NewSQLProjectRepository(db, driver) before, err := projects.GetProject(t.Context(), item.ID) if err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { if err := repo.UpdateManagedProject(t.Context(), "root", item.ID, "项目二", "新说明"); err != nil { t.Fatal(err) } } after, err := projects.GetProject(t.Context(), item.ID) if err != nil || !after.CreatedAt.Equal(before.CreatedAt) || after.ID != before.ID || after.Name != "项目二" { t.Fatalf("更新破坏身份:%+v,%v", after, err) } if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); err != nil { t.Fatal(err) } if _, err := projects.GetProject(t.Context(), item.ID); !errors.Is(err, sql.ErrNoRows) { t.Fatalf("项目未删除:%v", err) } var count int if err := db.QueryRowContext(t.Context(), Rebind(driver, `SELECT COUNT(*) FROM audit_logs WHERE resource_type='project' AND resource_id=? AND project_id=? AND scope_type='project' AND operator_id='root' AND status='success'`), item.ID, item.ID).Scan(&count); err != nil || count != 4 { t.Fatalf("删除后丢失真实审计:%d,%v", count, err) } }) for _, actor := range []string{"", "missing", "manager", "global-admin", "viewer", "disabled"} { run("create_denied_"+actor, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { if _, err := repo.CreateManagedProject(t.Context(), actor, "无权创建", "不应保存"); !errors.Is(err, ErrProjectAccessDenied) { t.Fatalf("错误授权:%v", err) } var count int if err := db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM projects`).Scan(&count); err != nil || count != 1 { t.Fatalf("拒绝后产生项目:%d,%v", count, err) } }) } run("project_manager_and_revocation", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { item, err := repo.CreateManagedProject(t.Context(), "root", "权限测试", "") if err != nil { t.Fatal(err) } if err := repo.GrantProjectRole(t.Context(), "root", item.ID, "manager", domain.ProjectRoleAdmin); err != nil { t.Fatal(err) } if err := repo.UpdateManagedProject(t.Context(), "manager", item.ID, "合法修改", ""); err != nil { t.Fatal(err) } for _, actor := range []string{"viewer", "global-admin", "disabled"} { if err := repo.UpdateManagedProject(t.Context(), actor, item.ID, "越权修改", ""); !errors.Is(err, ErrProjectAccessDenied) { t.Fatalf("错误更新授权:%s,%v", actor, err) } if err := repo.DeleteManagedProject(t.Context(), actor, item.ID); !errors.Is(err, ErrProjectAccessDenied) { t.Fatalf("错误删除授权:%s,%v", actor, err) } } if err := repo.DeleteManagedProject(t.Context(), "manager", item.ID); !errors.Is(err, ErrProjectReferenced) { t.Fatalf("不能自动清理成员:%v", err) } if err := repo.RevokeProjectRole(t.Context(), "root", item.ID, "manager", domain.ProjectRoleAdmin); err != nil { t.Fatal(err) } if err := repo.UpdateManagedProject(t.Context(), "manager", item.ID, "撤权后修改", ""); !errors.Is(err, ErrProjectAccessDenied) { t.Fatalf("撤权未生效:%v", err) } if err := repo.DeleteManagedProject(t.Context(), "manager", item.ID); !errors.Is(err, ErrProjectAccessDenied) { t.Fatalf("撤权后仍可删除:%v", err) } if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); err != nil { t.Fatal(err) } }) for _, kind := range []string{"domain", "server", "member"} { run("reference_"+kind, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { item, err := repo.CreateManagedProject(t.Context(), "root", "引用保护", "") if err != nil { t.Fatal(err) } switch kind { case "domain": resource, err := domain.NewDomain("lifecycle.example.test", domain.CertificateSingle, nil) if err != nil { t.Fatal(err) } resource.ProjectID = item.ID if err := NewSQLDomainRepository(db, driver).Create(t.Context(), resource); err != nil { t.Fatal(err) } case "server": resource := deployment.Server{ID: "server-a", Name: "项目服务器", Address: "192.0.2.10", Port: 22, Username: "deploy", AuthMethod: deployment.SSHAuthPassword, PasswordRef: "secret://ssh/password/test", HostKeyFingerprint: "SHA256:test", CertificatePath: "/test/cert.pem", PrivateKeyPath: "/test/key.pem", CertificateChainPath: "/test/chain.pem", WebServer: deployment.WebServerNginx} resource.ProjectID = item.ID if err := NewSQLServerRepository(db, driver).CreateServer(t.Context(), resource); err != nil { t.Fatal(err) } case "member": if err := repo.GrantProjectRole(t.Context(), "root", item.ID, "viewer", domain.ProjectRoleReadOnly); err != nil { t.Fatal(err) } } if err := repo.DeleteManagedProject(t.Context(), "root", item.ID); !errors.Is(err, ErrProjectReferenced) { t.Fatalf("引用保护失败:%v", err) } if _, err := NewSQLProjectRepository(db, driver).GetProject(t.Context(), item.ID); err != nil { t.Fatal(err) } }) } run("default_missing_and_cancelled", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { if err := repo.DeleteManagedProject(t.Context(), "root", domain.DefaultProjectID); !errors.Is(err, ErrDefaultProject) { t.Fatalf("默认项目删除:%v", err) } if err := repo.DeleteManagedProject(t.Context(), "viewer", domain.DefaultProjectID); !errors.Is(err, ErrProjectAccessDenied) { t.Fatalf("默认项目泄露权限:%v", err) } if err := repo.DeleteManagedProject(t.Context(), "root", "missing"); !errors.Is(err, ErrProjectAccessDenied) { t.Fatalf("不存在项目:%v", err) } ctx, cancel := context.WithCancel(t.Context()) cancel() if _, err := repo.CreateManagedProject(ctx, "root", "取消", ""); err == nil { t.Fatal("取消后创建成功") } }) for _, action := range []string{"create", "update", "delete"} { run("audit_rollback_"+action, func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { item, err := repo.CreateManagedProject(t.Context(), "root", "必须保留", "") if err != nil { t.Fatal(err) } executor := db trigger := `CREATE TRIGGER fail_project_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit-failure'); END` if driver == "mysql" { // 故障注入由隔离实例管理员执行,不提高业务权限或放宽二进制日志策略。 var schema string if err := db.QueryRowContext(t.Context(), "SELECT DATABASE()").Scan(&schema); err != nil { t.Fatal(err) } schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`" executor = admin trigger = `CREATE TRIGGER ` + schema + `.fail_project_audit BEFORE INSERT ON ` + schema + `.audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit-failure'` } if _, err := executor.ExecContext(t.Context(), trigger); err != nil { t.Fatal(err) } switch action { case "create": _, err = repo.CreateManagedProject(t.Context(), "root", "不能保存", "") case "update": err = repo.UpdateManagedProject(t.Context(), "root", item.ID, "不能保存", "") case "delete": err = repo.DeleteManagedProject(t.Context(), "root", item.ID) } if err == nil { t.Fatal("审计异常仍返回成功") } loaded, err := NewSQLProjectRepository(db, driver).GetProject(t.Context(), item.ID) if err != nil || loaded.Name != "必须保留" { t.Fatalf("项目未回滚:%+v,%v", loaded, err) } var count int if err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 2 { t.Fatalf("产生部分写入:%d,%v", count, err) } }) } run("nontransactional_audit_is_rejected", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { if driver != "mysql" { t.Skip("非事务引擎边界仅适用于 MySQL") } if _, err := db.ExecContext(t.Context(), "ALTER TABLE audit_logs ENGINE=MyISAM"); err != nil { t.Fatal(err) } if _, err := repo.CreateManagedProject(t.Context(), "root", "不能保存", ""); err == nil { t.Fatal("非事务审计表仍被允许") } var count int if err := db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM projects").Scan(&count); err != nil || count != 1 { t.Fatalf("非事务场景产生变更:%d,%v", count, err) } }) run("duplicate_names_and_validation", func(t *testing.T, db *sql.DB, repo *SQLProjectMemberRepository, driver string) { a, err := repo.CreateManagedProject(t.Context(), "root", "同名", "") if err != nil { t.Fatal(err) } b, err := repo.CreateManagedProject(t.Context(), "root", "同名", "") if err != nil || a.ID == b.ID { t.Fatalf("重名项目没有独立标识:%v", err) } for _, name := range []string{"", strings.Repeat("中", 129), "控制\x00字符"} { if _, err := repo.CreateManagedProject(t.Context(), "root", name, ""); err == nil { t.Fatal("无效名称创建成功") } } }) } func TestProjectLifecycleSQLiteAuditRollback(t *testing.T) { for _, action := range []string{"create", "update", "delete"} { for _, mode := range []string{"ABORT, 'audit-failure'", "IGNORE"} { t.Run(action+mode, func(t *testing.T) { cfg := config.Default(t.TempDir()).Database cfg.DSN = filepath.Join(t.TempDir(), "project-audit-rollback.db") db, err := database.Open(t.Context(), cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = db.Close() }) if err := database.Migrate(t.Context(), db.DB, cfg.Driver); err != nil { t.Fatal(err) } if _, err := db.DB.Exec(`INSERT INTO users (id, username, email, password_hash, role, status) VALUES ('root', 'root', '', 'test-only', 'super_admin', 'active')`); err != nil { t.Fatal(err) } repo := NewSQLProjectMemberRepository(db.DB, cfg.Driver) item, err := repo.CreateManagedProject(t.Context(), "root", "保留原名", "") if err != nil { t.Fatal(err) } if _, err := db.DB.Exec(`CREATE TRIGGER reject_project_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(` + mode + `); END`); err != nil { t.Fatal(err) } switch action { case "create": _, err = repo.CreateManagedProject(t.Context(), "root", "不能保存", "") case "update": err = repo.UpdateManagedProject(t.Context(), "root", item.ID, "不能保存", "") case "delete": err = repo.DeleteManagedProject(t.Context(), "root", item.ID) } if err == nil { t.Fatal("审计故障后仍然成功") } items, err := NewSQLProjectRepository(db.DB, cfg.Driver).ListProjects(t.Context()) if err != nil || len(items) != 2 { t.Fatalf("事务没有回滚:%+v,%v", items, err) } loaded, err := NewSQLProjectRepository(db.DB, cfg.Driver).GetProject(t.Context(), item.ID) if err != nil || loaded.Name != "保留原名" { t.Fatalf("资料或删除未回滚:%+v,%v", loaded, err) } }) } } }