feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.

This commit is contained in:
Florian Sylvain committed 2026-10-27 00:45:24 +01:00
1 parent a043f7f41a
commit 094df63a7f
51 files changed
+4822 -115

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
package test
import (
"RenewCMS/internal/domain/role"
"go/ast"
"go/token"
"path/filepath"
"strconv"
"strings"
"testing"
)
func rolesAPIRoleUseCaseFiles() []string {
return []string{
"../internal/infrastructure/useCases/roleSupport.go",
"../internal/infrastructure/useCases/roleView.go",
"../internal/infrastructure/useCases/ListRoles.go",
"../internal/infrastructure/useCases/GetRole.go",
"../internal/infrastructure/useCases/CreateRole.go",
"../internal/infrastructure/useCases/UpdateRole.go",
"../internal/infrastructure/useCases/DeleteRole.go",
"../internal/infrastructure/useCases/ReassignRoleMembers.go",
}
}
func TestRolesAPIHygiene(t *testing.T) {
t.Run("The comparison logic lives once, in the domain", func(t *testing.T) {
files := append(rolesAPIRoleUseCaseFiles(), nonTestGoFiles(t, "../api/handlers/role", "../api/middleware")...)
for _, path := range files {
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
selector, ok := node.(*ast.SelectorExpr)
if ok && (selector.Sel.Name == "ScopeAll" || selector.Sel.Name == "ScopeOwn" || selector.Sel.Name == "ScopeNone") {
t.Errorf("%s reads the scope %s", path, selector.Sel.Name)
}
return true
})
}
})
t.Run("api/router.go and the role handler hold no catalogue key literal", func(t *testing.T) {
keys := map[string]bool{}
for _, permission := range role.Catalogue() {
keys[permission.Key] = true
}
files := append(nonTestGoFiles(t, "../api/handlers/role"), "../api/router.go")
for _, path := range files {
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
literal, ok := node.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
return true
}
value, err := strconv.Unquote(literal.Value)
if err == nil && keys[value] {
t.Errorf("%s holds the literal %q", path, value)
}
return true
})
}
})
t.Run("no handler under api/handlers/role imports persistence, mailer, models or gorm", func(t *testing.T) {
files, _ := filepath.Glob("../api/handlers/role/*.go")
if len(files) == 0 {
t.Fatal("no role handler")
}
for _, file := range files {
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
for _, forbidden := range []string{"persistence", "mailer", "models", "gorm"} {
if strings.Contains(imported, forbidden) {
t.Errorf("%s imports %s", file, imported)
}
}
}
}
})
t.Run("the role use cases import only domain packages", func(t *testing.T) {
for _, path := range rolesAPIRoleUseCaseFiles() {
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(path), "../")) {
if strings.HasPrefix(imported, "RenewCMS/") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
t.Errorf("%s imports %s", path, imported)
}
}
}
})
t.Run("the router mounts every role route behind the keys", func(t *testing.T) {
source := readTestFile(t, "../api/router.go")
for _, wanted := range []string{"role.KeyRolesView", "role.KeyRolesManage", "NewRoleRouter"} {
if !strings.Contains(source, wanted) {
t.Errorf("api/router.go lacks %s", wanted)
}
}
})
}