239 lines
10 KiB
Go
239 lines
10 KiB
Go
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("空连接未拒绝")
|
||
}
|
||
}
|
||
}
|