Scert/backend/internal/repository/project_lifecycle_test.go

301 lines
12 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 (
"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
})
}
func TestProjectLifecycleMySQLReal(t *testing.T) {
server := mysqltest.New(t)
checkProjectLifecycle(t, server.Database)
}
func checkProjectLifecycle(t *testing.T, makeConfig func(*testing.T) config.DatabaseConfig) {
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",
PrivateKeyRef: "secret://ssh/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)
}
trigger := `CREATE TRIGGER fail_project_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit-failure'); END`
if driver == "mysql" {
trigger = `CREATE TRIGGER fail_project_audit BEFORE INSERT ON audit_logs FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='audit-failure'`
}
if _, err := db.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
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)
}
})
}
}
}