Scert/backend/internal/repository/project_member_changes_test.go

297 lines
12 KiB
Go
Raw Permalink 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"
"sync"
"testing"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/domain"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
type memberChangeFixture struct {
db *sql.DB
repo *SQLProjectMemberRepository
driver string
admin *sql.DB
}
func TestProjectMemberChangesSQLite(t *testing.T) {
checkProjectMemberChanges(t, func(t *testing.T) config.DatabaseConfig {
cfg := config.Default(t.TempDir()).Database
cfg.DSN = filepath.Join(t.TempDir(), "member-audit.db")
return cfg
}, nil)
}
func TestProjectMemberChangesMySQLReal(t *testing.T) {
server := mysqltest.New(t)
checkProjectMemberChanges(t, server.Database, server.Admin())
}
func checkProjectMemberChanges(t *testing.T, makeConfig func(*testing.T) config.DatabaseConfig, admin *sql.DB) {
t.Helper()
run := func(name string, test func(*testing.T, memberChangeFixture)) {
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)
}
projects := NewSQLProjectRepository(db.DB, cfg.Driver)
if err := projects.CreateProject(t.Context(), domain.Project{ID: "audit-project", Name: "审计测试项目"}); err != nil {
t.Fatal(err)
}
for _, u := range []struct{ id, role, status string }{
{"audit-root", UserRoleSuperAdmin, UserStatusActive},
{"audit-manager", UserRoleUser, UserStatusActive},
{"audit-target", UserRoleUser, UserStatusActive},
{"audit-disabled", UserRoleUser, UserStatusDisabled},
} {
_, err := db.DB.ExecContext(t.Context(), Rebind(cfg.Driver, `INSERT INTO users
(id, username, email, password_hash, role, status) VALUES (?, ?, ?, 'test-only-no-login', ?, ?)`),
u.id, u.id, u.id+"@example.invalid", u.role, u.status)
if err != nil {
t.Fatal(err)
}
}
// 夹具直接准备初始管理员,待验证的业务变更一律走正式仓储方法。
if _, err := db.DB.ExecContext(t.Context(), `INSERT INTO project_members (project_id, user_id, role)
VALUES ('audit-project', 'audit-manager', 'project_admin')`); err != nil {
t.Fatal(err)
}
test(t, memberChangeFixture{db: db.DB, repo: NewSQLProjectMemberRepository(db.DB, cfg.Driver), driver: cfg.Driver, admin: admin})
})
}
memberChangeSuccessCases(t, run)
memberChangeFailureCases(t, run)
memberChangeHistoryCases(t, run)
}
func memberChangeSuccessCases(t *testing.T, run func(string, func(*testing.T, memberChangeFixture))) {
t.Helper()
run("grant_and_revoke", func(t *testing.T, f memberChangeFixture) {
before := time.Now().UTC().Add(-time.Second)
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleDNSAdmin); err != nil {
t.Fatal(err)
}
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleDNSAdmin); err != nil {
t.Fatal(err)
}
assertMemberAudit(t, f, 2, 0)
rows, err := f.db.QueryContext(t.Context(), `SELECT action, resource_type, resource_id, status, message,
created_at, scope_type, project_id, task_id, operator_id FROM audit_logs ORDER BY id`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for _, action := range []string{"project_member.grant", "project_member.revoke"} {
var item AuditLog
if !rows.Next() {
t.Fatalf("缺少成员审计:%s", action)
}
if err := rows.Scan(&item.Action, &item.ResourceType, &item.ResourceID, &item.Status, &item.Message,
&item.CreatedAt, &item.ScopeType, &item.ProjectID, &item.TaskID, &item.OperatorID); err != nil {
t.Fatal(err)
}
if item.Action != action || item.ResourceType != "project_member" || item.ResourceID != "audit-target" ||
item.Status != "success" || item.ScopeType != ScopeProject || item.ProjectID != "audit-project" ||
item.OperatorID != "audit-manager" || item.TaskID != "" || !strings.Contains(item.Message, "dns_admin") ||
item.CreatedAt.Before(before) || item.CreatedAt.After(time.Now().UTC().Add(time.Second)) {
t.Fatalf("成员审计范围、角色或身份错误:%+v", item)
}
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
})
run("no_success_on_rejection", func(t *testing.T, f memberChangeFixture) {
for _, actor := range []string{"audit-target", "audit-disabled", "missing"} {
if err := f.repo.GrantProjectRole(t.Context(), actor, "audit-project", "audit-target", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("无权限的授予未拒绝:%v", err)
}
}
if err := f.repo.GrantProjectRole(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
if err := f.repo.GrantProjectRole(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("重复授予意外成功")
}
if err := f.repo.RevokeProjectRole(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleDNSAdmin); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("不存在的角色撤销未拒绝:%v", err)
}
assertMemberAudit(t, f, 1, 1)
})
}
func memberChangeFailureCases(t *testing.T, run func(string, func(*testing.T, memberChangeFixture))) {
t.Helper()
run("grant_audit_failure", func(t *testing.T, f memberChangeFixture) {
failMemberAudit(t, f)
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("审计失败仍返回授予成功")
}
assertMemberAudit(t, f, 0, 0)
})
run("revoke_audit_failure", func(t *testing.T, f memberChangeFixture) {
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
failMemberAudit(t, f)
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("审计失败仍返回撤销成功")
}
assertMemberAudit(t, f, 1, 1)
})
run("self_revoke_and_recovery", func(t *testing.T, f memberChangeFixture) {
restore := failMemberAudit(t, f)
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err == nil {
t.Fatal("审计失败仍撤销了自己的角色")
}
if err := f.repo.AuthorizeProject(t.Context(), "audit-manager", "audit-project", domain.PermissionMembersManage); err != nil {
t.Fatalf("审计失败没有恢复原权限:%v", err)
}
restore()
if err := f.repo.RevokeProjectRole(t.Context(), "audit-manager", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err != nil {
t.Fatal(err)
}
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-manager", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("自我撤权后仍可恢复:%v", err)
}
if err := f.repo.GrantProjectRole(t.Context(), "audit-root", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err != nil {
t.Fatal(err)
}
assertMemberAudit(t, f, 2, 0)
})
run("audit_storage_fail_closed", func(t *testing.T, f memberChangeFixture) {
query := `CREATE TRIGGER ignore_member_audit BEFORE INSERT ON audit_logs BEGIN SELECT RAISE(IGNORE); END`
if f.driver == "mysql" {
query = "ALTER TABLE audit_logs ENGINE=MyISAM"
}
if _, err := f.db.ExecContext(t.Context(), query); err != nil {
t.Fatal(err)
}
if err := f.repo.GrantProjectRole(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("审计被忽略或不可回滚时仍接受了成员变更")
}
assertMemberAudit(t, f, 0, 0)
})
}
func memberChangeHistoryCases(t *testing.T, run func(string, func(*testing.T, memberChangeFixture))) {
t.Helper()
run("concurrent_duplicate_changes", func(t *testing.T, f memberChangeFixture) {
for _, change := range []func(context.Context, string, string, string, domain.ProjectRole) error{
f.repo.GrantProjectRole, f.repo.RevokeProjectRole,
} {
var workers sync.WaitGroup
results := make(chan error, 6)
for range 6 {
workers.Add(1)
go func() {
defer workers.Done()
results <- change(t.Context(), "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly)
}()
}
workers.Wait()
close(results)
succeeded := 0
for err := range results {
if err == nil {
succeeded++
}
}
if succeeded != 1 {
t.Fatalf("并发重复变更成功次数错误:%d", succeeded)
}
}
assertMemberAudit(t, f, 2, 0)
})
run("history_survives_deletion", func(t *testing.T, f memberChangeFixture) {
for _, change := range []func(context.Context, string, string, string, domain.ProjectRole) error{
f.repo.GrantProjectRole, f.repo.RevokeProjectRole,
} {
if err := change(t.Context(), "audit-root", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err != nil {
t.Fatal(err)
}
}
if err := f.repo.RevokeProjectRole(t.Context(), "audit-root", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err != nil {
t.Fatal(err)
}
if err := NewSQLProjectRepository(f.db, f.driver).DeleteProject(t.Context(), "audit-project"); err != nil {
t.Fatal(err)
}
if _, err := f.db.ExecContext(t.Context(), "DELETE FROM users WHERE id IN ('audit-root', 'audit-target')"); err != nil {
t.Fatal(err)
}
assertMemberAudit(t, f, 3, 0)
var count int
if err := f.db.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM audit_logs WHERE scope_type='project'
AND project_id='audit-project' AND operator_id='audit-root'`).Scan(&count); err != nil || count != 3 {
t.Fatalf("历史项目或操作者快照丢失:%d,%v", count, err)
}
})
run("canceled_context", func(t *testing.T, f memberChangeFixture) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := f.repo.GrantProjectRole(ctx, "audit-manager", "audit-project", "audit-target", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("已取消的授予仍然成功")
}
if err := f.repo.RevokeProjectRole(ctx, "audit-root", "audit-project", "audit-manager", domain.ProjectRoleAdmin); err == nil {
t.Fatal("已取消的撤销仍然成功")
}
assertMemberAudit(t, f, 0, 0)
})
}
func failMemberAudit(t *testing.T, f memberChangeFixture) func() {
t.Helper()
executor := f.db
drop := "DROP TRIGGER fail_project_member_audit"
query := `CREATE TRIGGER fail_project_member_audit BEFORE INSERT ON audit_logs
WHEN NEW.resource_type='project_member' BEGIN SELECT RAISE(ABORT, 'test-only-audit-failure'); END`
if f.driver == "mysql" {
// 故障触发器由隔离实例管理员创建,不提升业务账户权限或放宽二进制日志策略。
var schema string
if err := f.db.QueryRowContext(t.Context(), "SELECT DATABASE()").Scan(&schema); err != nil {
t.Fatal(err)
}
schema = "`" + strings.ReplaceAll(schema, "`", "``") + "`"
executor = f.admin
query = `CREATE TRIGGER ` + schema + `.fail_project_member_audit BEFORE INSERT ON ` + schema + `.audit_logs FOR EACH ROW
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test-only-audit-failure'`
drop = "DROP TRIGGER " + schema + ".fail_project_member_audit"
}
if _, err := executor.ExecContext(t.Context(), query); err != nil {
t.Fatal(err)
}
return func() {
t.Helper()
if _, err := executor.ExecContext(t.Context(), drop); err != nil {
t.Fatal(err)
}
}
}
func assertMemberAudit(t *testing.T, f memberChangeFixture, auditCount, memberCount int) {
t.Helper()
var actual int
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs").Scan(&actual); err != nil || actual != auditCount {
t.Fatalf("审计数量错误,期望 %d,实际 %d:%v", auditCount, actual, err)
}
if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members WHERE user_id='audit-target'").Scan(&actual); err != nil || actual != memberCount {
t.Fatalf("成员数量错误,期望 %d,实际 %d:%v", memberCount, actual, err)
}
}