Scert/backend/internal/repository/project_members_test.go

239 lines
10 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"
"testing"
"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"
)
func TestProjectMembersSQLite(t *testing.T) {
cfg := config.Default(t.TempDir()).Database
cfg.DSN = filepath.Join(t.TempDir(), "members.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)
}
checkProjectMembers(t, db.DB, cfg.Driver)
}
func TestProjectMembersMySQLReal(t *testing.T) {
server := mysqltest.New(t)
cfg := server.Database(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)
}
checkProjectMembers(t, db.DB, cfg.Driver)
}
func checkProjectMembers(t *testing.T, db *sql.DB, driver string) {
t.Helper()
ctx := t.Context()
repo := NewSQLProjectMemberRepository(db, driver)
projects := NewSQLProjectRepository(db, 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)
}
}
for _, u := range []struct{ id, role, status string }{
{"root-test", UserRoleSuperAdmin, UserStatusActive},
{"manager", UserRoleUser, UserStatusActive},
{"viewer", UserRoleUser, UserStatusActive},
{"global-admin", UserRoleAdmin, UserStatusActive},
{"outsider", UserRoleUser, UserStatusActive},
{"disabled", UserRoleUser, UserStatusDisabled},
} {
_, err := db.ExecContext(ctx, Rebind(driver, `INSERT INTO users (id, username, email, password_hash, role, status, groups_json)
VALUES (?, ?, ?, 'test-only-no-login', ?, ?, '["same-group"]')`), u.id, u.id, u.id+"@example.invalid", u.role, u.status)
if err != nil {
t.Fatal(err)
}
}
grant := func(actor, project, user string, role domain.ProjectRole) {
t.Helper()
if err := repo.GrantProjectRole(ctx, actor, project, user, role); err != nil {
t.Fatal(err)
}
}
check := func(user, project string, permission domain.ProjectPermission, allowed bool) {
t.Helper()
err := repo.AuthorizeProject(ctx, user, project, permission)
if allowed && err != nil {
t.Fatalf("预期允许 %s/%s/%s:%v", user, project, permission, err)
}
if !allowed && !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("预期拒绝 %s/%s/%s:%v", user, project, permission, err)
}
}
t.Run("no_implicit_membership", func(t *testing.T) {
var count int
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM project_members").Scan(&count); err != nil || count != 0 {
t.Fatalf("迁移自动授予了权限:%d,%v", count, err)
}
for _, user := range []string{"manager", "global-admin", "outsider", "missing", "disabled"} {
check(user, "default", domain.PermissionProjectRead, false)
check(user, "project-a", domain.PermissionProjectRead, false)
}
check("root-test", "project-a", domain.PermissionProjectManage, true)
check("root-test", "missing", domain.PermissionProjectRead, false)
check("root-test", "project-a", "unknown", false)
})
grant("root-test", "project-a", "manager", domain.ProjectRoleAdmin)
t.Run("project_admin_and_private_key_boundary", func(t *testing.T) {
check("manager", "project-a", domain.PermissionMembersManage, true)
check("manager", "project-a", domain.PermissionPrivateKeyRead, false)
check("manager", "project-b", domain.PermissionProjectRead, false)
grant("manager", "project-a", "viewer", domain.ProjectRoleReadOnly)
check("viewer", "project-a", domain.PermissionCertificateRead, true)
check("viewer", "project-a", domain.PermissionCertificateManage, false)
check("outsider", "project-a", domain.PermissionCertificateRead, false)
})
t.Run("unauthorized_grants_and_invalid_targets", func(t *testing.T) {
for _, tc := range []struct {
actor, project, user string
role domain.ProjectRole
}{
{"viewer", "project-a", "viewer", domain.ProjectRoleAdmin},
{"global-admin", "project-a", "viewer", domain.ProjectRoleAdmin},
{"manager", "project-b", "viewer", domain.ProjectRoleAdmin},
{"root-test", "missing", "viewer", domain.ProjectRoleAdmin},
{"root-test", "project-a", "missing", domain.ProjectRoleAdmin},
{"root-test", "project-a", "disabled", domain.ProjectRoleAdmin},
{"root-test", "project-a", "viewer", "super_admin"},
} {
if err := repo.GrantProjectRole(ctx, tc.actor, tc.project, tc.user, tc.role); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("非法授予未拒绝:%+v,%v", tc, err)
}
}
if err := repo.GrantProjectRole(ctx, "root-test", "project-a", "viewer", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("重复角色未拒绝")
}
check("viewer", "project-a", domain.PermissionMembersManage, false)
})
t.Run("multiple_roles_and_projects", func(t *testing.T) {
grant("manager", "project-a", "viewer", domain.ProjectRoleDNSAdmin)
grant("root-test", "project-b", "viewer", domain.ProjectRoleAuditViewer)
check("viewer", "project-a", domain.PermissionDNSCredentialUse, true)
check("viewer", "project-a", domain.PermissionCertificateRead, true)
check("viewer", "project-a", domain.PermissionAuditRead, false)
check("viewer", "project-b", domain.PermissionAuditRead, true)
check("viewer", "project-b", domain.PermissionDNSCredentialUse, false)
})
t.Run("revocation_is_immediate_and_scoped", func(t *testing.T) {
if err := repo.RevokeProjectRole(ctx, "viewer", "project-a", "manager", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("越权撤销未拒绝:%v", err)
}
if err := repo.RevokeProjectRole(ctx, "manager", "project-b", "viewer", domain.ProjectRoleAuditViewer); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("跨项目撤销未拒绝:%v", err)
}
if err := repo.RevokeProjectRole(ctx, "manager", "project-a", "viewer", domain.ProjectRoleDNSAdmin); err != nil {
t.Fatal(err)
}
check("viewer", "project-a", domain.PermissionDNSCredentialUse, false)
check("viewer", "project-a", domain.PermissionCertificateRead, true)
check("viewer", "project-b", domain.PermissionAuditRead, true)
if err := repo.RevokeProjectRole(ctx, "manager", "project-a", "manager", domain.ProjectRoleAdmin); err != nil {
t.Fatal(err)
}
check("manager", "project-a", domain.PermissionMembersManage, false)
if err := repo.GrantProjectRole(ctx, "manager", "project-a", "manager", domain.ProjectRoleAdmin); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("撤权后可自行恢复:%v", err)
}
grant("root-test", "project-a", "manager", domain.ProjectRoleAdmin)
})
t.Run("disabled_and_demoted_accounts", func(t *testing.T) {
grant("root-test", "project-a", "global-admin", domain.ProjectRoleCertificateAdmin)
check("global-admin", "project-a", domain.PermissionPrivateKeyRead, true)
if _, err := db.ExecContext(ctx, "UPDATE users SET role = 'user' WHERE id = 'global-admin'"); err != nil {
t.Fatal(err)
}
check("global-admin", "project-a", domain.PermissionPrivateKeyRead, false)
check("global-admin", "project-a", domain.PermissionCertificateManage, true)
if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'disabled' WHERE id = 'manager'"); err != nil {
t.Fatal(err)
}
check("manager", "project-a", domain.PermissionProjectRead, false)
if err := repo.GrantProjectRole(ctx, "manager", "project-a", "outsider", domain.ProjectRoleReadOnly); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("禁用账户仍可授予:%v", err)
}
if err := repo.RevokeProjectRole(ctx, "manager", "project-a", "viewer", domain.ProjectRoleReadOnly); !errors.Is(err, ErrProjectAccessDenied) {
t.Fatalf("禁用账户仍可撤销:%v", err)
}
if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'active' WHERE id = 'manager'"); err != nil {
t.Fatal(err)
}
if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'disabled' WHERE id = 'root-test'"); err != nil {
t.Fatal(err)
}
check("root-test", "project-a", domain.PermissionMembersManage, false)
if _, err := db.ExecContext(ctx, "UPDATE users SET status = 'active' WHERE id = 'root-test'"); err != nil {
t.Fatal(err)
}
})
t.Run("parent_references", func(t *testing.T) {
if err := projects.DeleteProject(ctx, "project-b"); err == nil {
t.Fatal("删除了仍有成员的项目")
}
if _, err := db.ExecContext(ctx, "DELETE FROM users WHERE id = 'viewer'"); err == nil {
t.Fatal("删除了仍有项目角色的用户")
}
for _, query := range []string{
"INSERT INTO project_members(project_id,user_id,role) VALUES ('missing','viewer','read_only')",
"INSERT INTO project_members(project_id,user_id,role) VALUES ('project-a','missing','read_only')",
"UPDATE project_members SET user_id='missing' WHERE user_id='viewer'",
"UPDATE project_members SET project_id='missing' WHERE user_id='viewer'",
} {
if _, err := db.ExecContext(ctx, query); err == nil {
t.Fatalf("无效引用未拒绝:%s", query)
}
}
if err := repo.RevokeProjectRole(ctx, "root-test", "project-b", "viewer", domain.ProjectRoleAuditViewer); err != nil {
t.Fatal(err)
}
if err := projects.DeleteProject(ctx, "project-b"); err != nil {
t.Fatal(err)
}
check("viewer", "project-b", domain.PermissionAuditRead, false)
})
t.Run("canceled_context", func(t *testing.T) {
canceled, cancel := context.WithCancel(ctx)
cancel()
if err := repo.AuthorizeProject(canceled, "root-test", "project-a", domain.PermissionProjectRead); err == nil {
t.Fatal("取消请求意外授权")
}
if err := repo.GrantProjectRole(canceled, "root-test", "project-a", "outsider", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("取消请求意外写入")
}
})
}
func TestProjectMembersNilDatabase(t *testing.T) {
for _, repo := range []*SQLProjectMemberRepository{nil, NewSQLProjectMemberRepository(nil)} {
if err := repo.AuthorizeProject(t.Context(), "user", "project", domain.PermissionProjectRead); err == nil {
t.Fatal("空连接未拒绝")
}
if err := repo.GrantProjectRole(t.Context(), "actor", "project", "user", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("空连接未拒绝")
}
if err := repo.RevokeProjectRole(t.Context(), "actor", "project", "user", domain.ProjectRoleReadOnly); err == nil {
t.Fatal("空连接未拒绝")
}
}
}