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

+1 -1
View File
@@ -56,7 +56,7 @@ func TestActivityDomain(t *testing.T) {
wanted []string
}{
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "authors", "media", "sites", "system", "users"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "authors", "media", "roles", "sites", "system", "users"}},
{"outcome", activityStrings(audit.Outcomes()), []string{"failed", "success"}},
{"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}},
{"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}},
+7
View File
@@ -54,6 +54,7 @@ var routesNotYetInTheCatalogue = []string{
"DELETE /v1/content-type/{key}/entry/{id}",
"DELETE /v1/image-variants/{name}",
"DELETE /v1/media/{id}/variants/{name}",
"DELETE /v1/role/{id}",
"GET /v1/builtin-type",
"GET /v1/builtin-type/{key}",
"GET /v1/content-type",
@@ -65,6 +66,8 @@ var routesNotYetInTheCatalogue = []string{
"GET /v1/media/{id}/variants",
"GET /v1/user/lookup",
"GET /v1/permission",
"GET /v1/role",
"GET /v1/role/{id}",
"PATCH /v1/image-variants/{name}",
"POST /v1/content-type",
"POST /v1/content-type/{key}/entry",
@@ -73,9 +76,13 @@ var routesNotYetInTheCatalogue = []string{
"POST /v1/image-variants/{name}/purge",
"POST /v1/media/{id}/variants",
"POST /v1/media/{id}/variants/{name}",
"POST /v1/role",
"POST /v1/role/{id}/reassign",
"POST /v1/role/{id}/reset",
"PUT /v1/content-type/{key}",
"PUT /v1/content-type/{key}/entry/{id}",
"PUT /v1/image-variants/{name}",
"PUT /v1/role/{id}",
}
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
@@ -217,7 +217,7 @@ func TestContentTypeIconUseCase(t *testing.T) {
repo := newMockContentTypeRepo()
repo.types[1] = contenttype.ContentType{ID: 1, Key: "dish", Name: "Dish", Fields: fields}
bad := icon
_, err := useCases.NewCreateContentTypeUseCase(repo, now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
_, err := useCases.NewCreateContentTypeUseCase(repo, newMockRoleRepo(), now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.createCalls != 0 {
t.Errorf("create: err %v, calls %d", err, repo.createCalls)
}
@@ -423,7 +423,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, newMockRoleRepo(), now),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, newMockImageRepo(), now),
}
router := chi.NewRouter()
+2 -2
View File
@@ -268,11 +268,11 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, newMockRoleRepo(), now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
+2 -2
View File
@@ -151,7 +151,7 @@ func TestDeleteContentTypeUseCase(t *testing.T) {
t.Run("Use case: DeleteContentType with entries returns ErrHasEntries and nothing is deleted", func(t *testing.T) {
fixture, stored := newContentTypeFixture(t)
_, _ = fixture.entries.Create(entry.Entry{ContentTypeID: stored.ID, Data: map[string]any{}})
remove := useCases.NewDeleteContentTypeUseCase(fixture.types, fixture.entries)
remove := useCases.NewDeleteContentTypeUseCase(fixture.types, fixture.entries, newMockRoleRepo())
err := remove.Delete("recipe")
@@ -593,7 +593,7 @@ func TestContentTypeNameRule(t *testing.T) {
t.Run("The use cases enforce it for non-HTTP callers", func(t *testing.T) {
fixture, existing := newContentTypeFixture(t)
create := useCases.NewCreateContentTypeUseCase(fixture.types, fixture.now)
create := useCases.NewCreateContentTypeUseCase(fixture.types, newMockRoleRepo(), fixture.now)
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
if _, err := create.Create(useCases.CreateContentTypeCommand{Key: "fresh", Name: " ", Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
t.Errorf("create blank: %v", err)
+2 -2
View File
@@ -124,7 +124,7 @@ func TestRelationFieldUseCases(t *testing.T) {
t.Run("DeleteContentType returns ErrReferenced for a target type", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries)
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo())
if err := remove.Delete("venue"); !errors.Is(err, contenttype.ErrReferenced) {
t.Errorf("venue: %v", err)
@@ -231,7 +231,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.types.Create(contenttype.ContentType{Key: "node", Name: "Node", Fields: []contenttype.Field{{Key: "parent", Type: contenttype.TypeRelation, Relation: &contenttype.Relation{ContentType: "node", Kind: contenttype.RelationOneToMany}}}})
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries).Delete("node"); err != nil {
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()).Delete("node"); err != nil {
t.Errorf("err %v", err)
}
})
+3 -3
View File
@@ -42,10 +42,10 @@ func TestRoleRepositoryFailures(t *testing.T) {
}
})
t.Run("ListGrants and role loading fail when grants cannot be read", func(t *testing.T) {
t.Run("List and role loading fail when grants cannot be read", func(t *testing.T) {
repo := broken(t, "role_permissions")
if _, err := repo.ListGrants(1); err == nil {
t.Error("ListGrants: expected error")
if _, err := repo.List(); err == nil {
t.Error("List: expected error")
}
if _, err := repo.Get(1); err == nil {
t.Error("Get: expected error")
+91 -15
View File
@@ -12,13 +12,16 @@ const (
)
type mockRoleRepo struct {
roles map[uint32]role.Role
nextID uint32
fail failSwitch
roles map[uint32]role.Role
nextID uint32
fail failSwitch
users *mockUserRepo
calls map[string]int
beforeSave func()
}
func newMockRoleRepo() *mockRoleRepo {
return &mockRoleRepo{roles: map[uint32]role.Role{}}
return &mockRoleRepo{roles: map[uint32]role.Role{}, calls: map[string]int{}}
}
func newSeededMockRoleRepo() *mockRoleRepo {
@@ -44,6 +47,7 @@ func (m *mockRoleRepo) sortedIDs() []uint32 {
}
func (m *mockRoleRepo) List() ([]role.Role, error) {
m.calls["List"]++
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
@@ -55,6 +59,7 @@ func (m *mockRoleRepo) List() ([]role.Role, error) {
}
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
m.calls["Get"]++
if err := m.fail.trigger("Get"); err != nil {
return role.Role{}, err
}
@@ -66,6 +71,7 @@ func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
}
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
m.calls["GetByName"]++
if err := m.fail.trigger("GetByName"); err != nil {
return role.Role{}, err
}
@@ -82,18 +88,8 @@ func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
return *match, nil
}
func (m *mockRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
if err := m.fail.trigger("ListGrants"); err != nil {
return nil, err
}
found, ok := m.roles[roleID]
if !ok {
return nil, role.ErrRoleNotFound
}
return slices.Clone(found.Grants), nil
}
func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
m.calls["Create"]++
if err := m.fail.trigger("Create"); err != nil {
return role.Role{}, err
}
@@ -104,9 +100,15 @@ func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
}
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
m.calls["Save"]++
if err := m.fail.trigger("Save"); err != nil {
return role.Role{}, err
}
if m.beforeSave != nil {
hook := m.beforeSave
m.beforeSave = nil
hook()
}
stored, ok := m.roles[saved.ID]
if !ok {
return role.Role{}, role.ErrRoleNotFound
@@ -118,8 +120,82 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
stored.Description = saved.Description
stored.Version++
stored.Grants = slices.Clone(saved.Grants)
stored.UpdatedAt = saved.UpdatedAt
stored.UpdatedBy = saved.UpdatedBy
m.roles[saved.ID] = stored
return stored, nil
}
func (m *mockRoleRepo) memberCount(roleID uint32) int {
if m.users == nil {
return 0
}
count := 0
for _, member := range m.users.users {
if member.RoleID == roleID {
count++
}
}
return count
}
func (m *mockRoleRepo) Delete(id uint32) error {
m.calls["Delete"]++
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.roles[id]; !ok {
return role.ErrRoleNotFound
}
if m.memberCount(id) > 0 {
return role.ErrRoleHasMembers
}
delete(m.roles, id)
return nil
}
func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
m.calls["ReassignMembers"]++
if err := m.fail.trigger("ReassignMembers"); err != nil {
return 0, err
}
if m.users == nil {
return 0, nil
}
moved := 0
for id, member := range m.users.users {
if member.RoleID == from {
member.RoleID = to
m.users.users[id] = member
moved++
}
}
return moved, nil
}
func (m *mockRoleRepo) RemoveTypedGrants(typeKey string) (int, error) {
m.calls["RemoveTypedGrants"]++
if err := m.fail.trigger("RemoveTypedGrants"); err != nil {
return 0, err
}
doomed := role.TypedKeys(typeKey)
affected := 0
for id, stored := range m.roles {
kept := []role.Grant{}
for _, grant := range stored.Grants {
if !slices.Contains(doomed, grant.Key) {
kept = append(kept, grant)
}
}
if len(kept) == len(stored.Grants) {
continue
}
stored.Grants = kept
stored.Version++
m.roles[id] = stored
affected++
}
return affected, nil
}
var _ role.Repository = &mockRoleRepo{}
+233
View File
@@ -0,0 +1,233 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"errors"
"reflect"
"testing"
)
func escalationKeyOf(t *testing.T, err error) string {
t.Helper()
var escalation role.EscalationError
if !errors.As(err, &escalation) || !errors.Is(err, role.ErrEscalation) {
t.Fatalf("expected an EscalationError, got %v", err)
}
return escalation.Key
}
func TestGrantSetCovers(t *testing.T) {
typeKeys := []string{"article", "events", "venues"}
builtIns := role.BuiltInRoles()
ownerSet, adminSet, writerSet := builtIns[0].GrantSet(), builtIns[1].GrantSet(), builtIns[2].GrantSet()
cases := []struct {
name string
caller role.GrantSet
candidate role.GrantSet
wantKey string
}{
{"owner covers admin", ownerSet, adminSet, ""},
{"owner covers writer", ownerSet, writerSet, ""},
{"admin does not cover owner and names the first owner-only key", adminSet, ownerSet, role.KeySettingsDeleteSite},
{"admin covers writer", adminSet, writerSet, ""},
{"writer does not cover admin", writerSet, adminSet, role.KeyContentDelete},
{"own covers own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=own"), ""},
{"own does not cover all", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=all"), role.KeyContentPublish},
{"none does not cover own", unlockedGrantSet("content.read=all"), unlockedGrantSet("content.delete=own"), role.KeyContentDelete},
{"denying never escalates", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=none"), ""},
{"typed all above wildcard own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=all"), "content.publish:events"},
{"typed own under wildcard own", unlockedGrantSet("content.update=own"), unlockedGrantSet("content.update:events=own"), ""},
{"wildcard all would raise article", unlockedGrantSet("content.update=all", "content.update:article=none"), unlockedGrantSet("content.update=all"), "content.update:article"},
{"typed-only reader does not cover wildcard read", unlockedGrantSet("content.read:events=all"), unlockedGrantSet("content.read=all"), role.KeyContentRead},
{"toggle missing", unlockedGrantSet("content.read=all"), unlockedGrantSet("schema.edit=all"), role.KeySchemaEdit},
{"first violation follows the catalogue order", unlockedGrantSet(), unlockedGrantSet("content.delete=all", "content.read=all"), role.KeyContentRead},
{"wildcard is checked before its typed keys", unlockedGrantSet(), unlockedGrantSet("content.read:events=all", "content.read=all"), role.KeyContentRead},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.caller.Covers(c.candidate, typeKeys)
if c.wantKey == "" {
if err != nil {
t.Fatalf("expected the grants to be covered, got %v", err)
}
return
}
if got := escalationKeyOf(t, err); got != c.wantKey {
t.Fatalf("expected the key %q, got %q", c.wantKey, got)
}
})
}
t.Run("a set always covers itself", func(t *testing.T) {
for _, set := range []role.GrantSet{ownerSet, adminSet, writerSet, unlockedGrantSet(), unlockedGrantSet("content.update=all", "content.update:article=none", "content.delete:events=none")} {
if err := set.Covers(set, typeKeys); err != nil {
t.Fatalf("expected a set to cover itself, got %v", err)
}
}
})
}
func TestGrantSetEffectiveGrants(t *testing.T) {
t.Run("the events editor lists typed entries after the wildcard in catalogue then type order", func(t *testing.T) {
got := role.Role{Grants: grantRows("content.delete:events=none", "content.update:venues=all", "content.read=all", "content.update:events=own")}.EffectiveGrants()
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("duplicates resolve to the most restrictive scope and unknown keys are dropped", func(t *testing.T) {
got := role.Role{Grants: grantRows("content.update=all", "content.update=own", "content.explode=all", "content.update:Bad Key=all")}.EffectiveGrants()
want := grantRows("content.update=own")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("owner-only keys are dropped on an unlocked role and kept on a locked one", func(t *testing.T) {
grants := grantRows("system.apply_updates=all", "content.read=all")
unlocked := role.Role{Grants: grants}.EffectiveGrants()
if !reflect.DeepEqual(unlocked, grantRows("content.read=all")) {
t.Fatalf("unexpected unlocked grants %v", unlocked)
}
locked := role.Role{Locked: true, Grants: grants}.EffectiveGrants()
if !reflect.DeepEqual(locked, grantRows("content.read=all", "system.apply_updates=all")) {
t.Fatalf("unexpected locked grants %v", locked)
}
})
t.Run("a role with nothing granted lists an empty slice, never nil", func(t *testing.T) {
if got := (role.Role{}).EffectiveGrants(); got == nil || len(got) != 0 {
t.Fatalf("expected an empty non-nil slice, got %#v", got)
}
})
t.Run("wildcard entries without effect are not listed", func(t *testing.T) {
if got := (role.Role{Grants: grantRows("content.create=own", "content.read=none")}).EffectiveGrants(); len(got) != 0 {
t.Fatalf("expected nothing, got %v", got)
}
})
t.Run("the effective list is valid as the grants of a write", func(t *testing.T) {
listed := role.Role{Grants: grantRows("content.update=own", "content.update=all", "content.delete:events=none", "media.view=all")}.EffectiveGrants()
if err := role.ValidateGrants(false, listed); err != nil {
t.Fatalf("expected the list to validate, got %v", err)
}
})
}
func TestDiffGrants(t *testing.T) {
t.Run("the example edit gives added, removed and changed", func(t *testing.T) {
before := grantRows("content.read=all", "content.update=own", "media.view=all")
after := grantRows("content.read=all", "content.update=all", "content.delete=own")
diff := role.DiffGrants(before, after)
if !reflect.DeepEqual(diff.Added, grantRows("content.delete=own")) {
t.Errorf("unexpected added %v", diff.Added)
}
if !reflect.DeepEqual(diff.Removed, grantRows("media.view=all")) {
t.Errorf("unexpected removed %v", diff.Removed)
}
if !reflect.DeepEqual(diff.Changed, []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
t.Errorf("unexpected changed %v", diff.Changed)
}
})
t.Run("an absent typed entry against a typed none is an addition", func(t *testing.T) {
diff := role.DiffGrants(nil, grantRows("content.delete:events=none"))
if len(diff.Added) != 1 || len(diff.Removed) != 0 || len(diff.Changed) != 0 {
t.Fatalf("unexpected diff %+v", diff)
}
})
t.Run("an unchanged list gives empty non-nil slices", func(t *testing.T) {
diff := role.DiffGrants(grantRows("content.read=all"), grantRows("content.read=all"))
if diff.Added == nil || diff.Removed == nil || diff.Changed == nil || !diff.Empty() {
t.Fatalf("expected empty non-nil slices, got %+v", diff)
}
})
}
func TestRoleWriteRules(t *testing.T) {
t.Run("the new sentinels are distinct and match with errors.Is", func(t *testing.T) {
sentinels := []error{role.ErrRoleHasMembers, role.ErrRoleNameTaken, role.ErrRoleLocked, role.ErrBuiltInRole, role.ErrBuiltInName, role.ErrOwnRole, role.ErrInvalidRoleName, role.ErrUnknownContentType, role.ErrNotBuiltIn, role.ErrInvalidReassign, role.ErrEscalation}
for index, sentinel := range sentinels {
for other, candidate := range sentinels {
if (index == other) != errors.Is(sentinel, candidate) {
t.Fatalf("sentinels %d and %d are not distinct", index, other)
}
}
}
})
t.Run("a name is trimmed and a blank one is refused", func(t *testing.T) {
name, err := role.NormalizeName(" Reviewer ")
if err != nil || name != "Reviewer" {
t.Fatalf("expected Reviewer, got %q %v", name, err)
}
if _, err := role.NormalizeName(" "); !errors.Is(err, role.ErrInvalidRoleName) {
t.Fatalf("expected ErrInvalidRoleName, got %v", err)
}
})
t.Run("names collide case-insensitively", func(t *testing.T) {
if !role.SameName(" contributor ", "Contributor") || role.SameName("Intern", "Contributor") {
t.Fatal("unexpected name comparison")
}
})
t.Run("typed keys must belong to an allowed type", func(t *testing.T) {
allowed := []string{"article", "events"}
if err := role.ValidateGrantTypes(grantRows("content.update:events=all", "content.read=all"), allowed); err != nil {
t.Fatalf("expected valid types, got %v", err)
}
for _, key := range []string{"content.update:nope=all", "content.update:author=all"} {
if err := role.ValidateGrantTypes(grantRows(key), allowed); !errors.Is(err, role.ErrUnknownContentType) {
t.Fatalf("%s: expected ErrUnknownContentType, got %v", key, err)
}
}
})
t.Run("the typed keys of a content type are the five per-type permissions", func(t *testing.T) {
want := []string{"content.read:events", "content.create:events", "content.update:events", "content.delete:events", "content.publish:events"}
if got := role.TypedKeys("events"); !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("the repository no longer lists grants on their own", func(t *testing.T) {
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName("ListGrants"); present {
t.Fatal("role.Repository must not have ListGrants")
}
for _, method := range []string{"List", "Get", "GetByName", "Create", "Save", "Delete", "ReassignMembers", "RemoveTypedGrants"} {
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName(method); !present {
t.Fatalf("role.Repository lacks %s", method)
}
}
})
}
func TestRoleActivityVocabulary(t *testing.T) {
t.Run("the roles category, target and actions exist", func(t *testing.T) {
if audit.CategoryRoles != "roles" || audit.TargetRole != "role" {
t.Fatalf("unexpected category %q or target %q", audit.CategoryRoles, audit.TargetRole)
}
actions := []string{audit.ActionRoleCreate, audit.ActionRoleEdit, audit.ActionRoleDelete, audit.ActionRoleReassign}
if !reflect.DeepEqual(actions, []string{"role.create", "role.edit", "role.delete", "role.reassign"}) {
t.Fatalf("unexpected actions %v", actions)
}
})
t.Run("an event in the roles category is valid and the filter accepts it", func(t *testing.T) {
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
event.Source = audit.SourceWeb
event.Actor = audit.AnonymousActor("x")
if err := event.Validate(); err != nil {
t.Fatalf("expected a valid event, got %v", err)
}
if category, err := audit.ParseCategory("roles"); err != nil || category != audit.CategoryRoles {
t.Fatalf("expected the roles filter, got %q %v", category, err)
}
})
}
+214
View File
@@ -0,0 +1,214 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"log"
"net/http"
"os"
"reflect"
"testing"
)
func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
t.Run("ReassignMembers refuses a target that does not exist and moves nobody", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
createUserRow(t, db, 1, "", &source.ID)
moved, err := repo.ReassignMembers(source.ID, 9999)
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 {
t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err)
}
if roleIDOf(userRow(t, db, 1)) != source.ID {
t.Fatal("the member must stay")
}
})
t.Run("ReassignMembers from an empty role to an existing one is a plain zero", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
writer := storedRole(t, db, "Writer")
moved, err := repo.ReassignMembers(source.ID, writer.ID)
if err != nil || moved != 0 {
t.Fatalf("expected 0 nil, got %d %v", moved, err)
}
})
t.Run("Create re-checks the name case-insensitively", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
for _, name := range []string{"contributor", "CONTRIBUTOR", " Contributor "} {
_, err := repo.Create(role.Role{Name: name, Version: 1})
if !errors.Is(err, role.ErrRoleNameTaken) {
t.Errorf("%q: expected ErrRoleNameTaken, got %v", name, err)
}
}
listed, _ := repo.List()
if len(listed) != 4 {
t.Fatalf("no role may be added, got %d", len(listed))
}
})
t.Run("Save re-checks the name against other roles only", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
first := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
second := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
clash := second
clash.Name = "contributor"
if _, err := repo.Save(clash); !errors.Is(err, role.ErrRoleNameTaken) {
t.Fatalf("expected ErrRoleNameTaken, got %v", err)
}
if stored, _ := repo.Get(second.ID); stored.Name != "Intern" || stored.Version != 1 {
t.Fatalf("the role must be unchanged: %+v", stored)
}
recased := first
recased.Name = "CONTRIBUTOR"
if saved, err := repo.Save(recased); err != nil || saved.Name != "CONTRIBUTOR" {
t.Fatalf("a role may change the case of its own name, got %+v %v", saved, err)
}
})
t.Run("Delete with the row lock still removes a memberless role and refuses one with a member", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
createUserRow(t, db, 1, "", &kept.ID)
if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
}
if err := repo.Delete(gone.ID); err != nil {
t.Fatal(err)
}
})
}
func TestRolesAPIHardeningUseCases(t *testing.T) {
t.Run("stale typed rows never show in a view and a GET body stays a valid PUT body", func(t *testing.T) {
world := newRolesAPIWorld(t)
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.read=all", "content.update:ghost=all", "content.update:events=own")})
got, err := world.container.GetRoleUseCase.Get(stale.ID)
if err != nil {
t.Fatal(err)
}
want := []string{"content.read=all", "content.update:events=own"}
if !reflect.DeepEqual(rolesAPIGrantKeys(got.Grants), want) {
t.Fatalf("unexpected grants %v", rolesAPIGrantKeys(got.Grants))
}
listed, err := world.container.ListRolesUseCase.List()
if err != nil {
t.Fatal(err)
}
for _, item := range listed {
if item.ID == stale.ID && !reflect.DeepEqual(rolesAPIGrantKeys(item.Grants), want) {
t.Fatalf("unexpected listed grants %v", rolesAPIGrantKeys(item.Grants))
}
}
updated, err := world.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{Caller: world.caller(raAdmin), ID: stale.ID, Name: "Stale", Grants: got.Grants, Version: got.Version})
if err != nil {
t.Fatalf("a GET body must be accepted, got %v", err)
}
if updated.Version != 1 || len(world.recorder.events) != 0 {
t.Fatalf("a body identical to the view is a no-op: version %d events %d", updated.Version, len(world.recorder.events))
}
})
t.Run("a failed content type create does not bump any role", func(t *testing.T) {
world := newRolesAPIWorld(t)
delete(world.types.types, raEventsTypeID)
world.types.fail.failNext("Create")
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err == nil {
t.Fatal("expected the create to fail")
}
if world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
t.Fatal("no role may be touched by a failed create")
}
})
t.Run("a failed cleanup after a create is reported", func(t *testing.T) {
world := newRolesAPIWorld(t)
delete(world.types.types, raEventsTypeID)
world.roles.fail.failNext("RemoveTypedGrants")
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err == nil {
t.Fatal("expected the failure to be reported")
}
})
t.Run("a target that vanished is an invalid reassign and records nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNextWith("ReassignMembers", role.ErrInvalidReassign)
moved, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"])
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %d %v events=%d", moved, err, len(world.recorder.events))
}
})
t.Run("a reassign failure event carries from, to and a zero moved", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("ReassignMembers")
_, _ = world.reassign(raAdmin, "Contributor", world.ids["Writer"])
params := world.recorder.events[0].Params
if params[audit.ParamFrom] != world.ids["Contributor"] || params[audit.ParamTo] != world.ids["Writer"] || params[audit.ParamMoved] != 0 {
t.Fatalf("unexpected params %+v", params)
}
})
t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("GetByName")
err := world.remove(raAdmin, "Intern")
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err)
}
if world.roles.calls["Delete"] != 0 {
t.Fatal("nothing may be deleted")
}
})
t.Run("a content type that references itself can be deleted but one referenced by another cannot", func(t *testing.T) {
world := newRolesAPIWorld(t)
selfRef := validContentTypeFields()
selfRef = append(selfRef, contenttype.Field{Key: "parent", Label: "Parent", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "events", Kind: contenttype.RelationOneToMany}})
events := world.types.types[raEventsTypeID]
events.Fields = selfRef
world.types.types[raEventsTypeID] = events
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err != nil {
t.Fatalf("a self reference must not block the delete, got %v", err)
}
venues := world.types.types[raVenuesTypeID]
venues.Fields = append(validContentTypeFields(), contenttype.Field{Key: "owner", Label: "Owner", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "other", Kind: contenttype.RelationOneToMany}})
world.types.types[raVenuesTypeID] = venues
world.types.types[7] = contenttype.ContentType{ID: 7, Key: "other", Name: "Other", Fields: validContentTypeFields()}
if err := world.container.DeleteContentTypeUseCase.Delete("other"); !errors.Is(err, contenttype.ErrReferenced) {
t.Fatalf("expected ErrReferenced, got %v", err)
}
})
}
func TestEscalationErrorMessage(t *testing.T) {
err := role.EscalationError{Key: "content.update:events"}
if err.Error() != role.ErrEscalation.Error()+": content.update:events" || !errors.Is(err, role.ErrEscalation) {
t.Fatalf("unexpected error %q", err.Error())
}
}
func TestRolesAPIHardeningQuietSuccess(t *testing.T) {
world := newRolesAPIWorld(t)
var sink bytes.Buffer
log.SetOutput(&sink)
defer log.SetOutput(os.Stderr)
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if rec.Code != http.StatusOK || sink.Len() != 0 {
t.Fatalf("a served response must not log: %d %q", rec.Code, sink.String())
}
}
+560
View File
@@ -0,0 +1,560 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
type rolesAPIBody struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []role.Grant `json:"grants"`
Members int `json:"members"`
UpdatedAt *string `json:"updated_at"`
UpdatedBy *struct {
ID uint32 `json:"id"`
Name string `json:"name"`
} `json:"updated_by"`
}
func decodeRolesAPIBody(t *testing.T, rec *httptest.ResponseRecorder, status int) rolesAPIBody {
t.Helper()
if rec.Code != status {
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
}
var body rolesAPIBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return body
}
func assertRolesAPIResult(t *testing.T, rec *httptest.ResponseRecorder, want string) {
t.Helper()
switch {
case strings.HasPrefix(want, "gate:"):
assertScopeWant(t, rec, refusedWant(strings.TrimPrefix(want, "gate:")))
case strings.HasPrefix(want, "escalation"):
assertEscalation(t, rec, strings.TrimPrefix(strings.TrimPrefix(want, "escalation"), ":"))
case strings.HasPrefix(want, "message:"):
parts := strings.SplitN(strings.TrimPrefix(want, "message:"), ":", 2)
status, _ := strconv.Atoi(parts[0])
assertAPIKeyMessage(t, rec, status, parts[1])
case strings.Contains(want, " "):
parts := strings.SplitN(want, " ", 2)
status, _ := strconv.Atoi(parts[0])
assertRefusal(t, rec, status, parts[1])
default:
status, _ := strconv.Atoi(want)
if rec.Code != status {
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
}
}
}
func TestRolesAPIGate(t *testing.T) {
rows := []struct {
name string
caller uint32
method string
path string
body string
want string
}{
{"Owner lists", raOwner, http.MethodGet, "/v1/role", "", "200"},
{"Admin lists", raAdmin, http.MethodGet, "/v1/role", "", "200"},
{"Viewer lists", raViewer, http.MethodGet, "/v1/role", "", "200"},
{"Viewer reads the Writer", raViewer, http.MethodGet, "/v1/role/3", "", "200"},
{"Writer lists", raWriter, http.MethodGet, "/v1/role", "", "gate:roles.view"},
{"Writer reads a malformed id", raWriter, http.MethodGet, "/v1/role/abc", "", "gate:roles.view"},
{"Viewer creates", raViewer, http.MethodPost, "/v1/role", `{"name": "X"}`, "gate:roles.manage"},
{"Viewer edits before any lookup", raViewer, http.MethodPut, "/v1/role/999", "", "gate:roles.manage"},
{"Viewer deletes", raViewer, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
{"Viewer reassigns", raViewer, http.MethodPost, "/v1/role/999/reassign", "", "gate:roles.manage"},
{"Viewer resets", raViewer, http.MethodPost, "/v1/role/3/reset", `{"version": 1}`, "gate:roles.manage"},
{"Writer edits a malformed id", raWriter, http.MethodPut, "/v1/role/abc", "", "gate:roles.manage"},
{"Writer deletes an unknown id", raWriter, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
{"no cookie", 0, http.MethodGet, "/v1/role", "", "message:401:" + apperr.UnauthorizedErrorMessage},
{"unverified Admin", raUnverified, http.MethodGet, "/v1/role", "", "message:403:" + apperr.VerificationRequiredErrorMessage},
}
for _, c := range rows {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path, c.body), c.want)
if c.method != http.MethodGet && len(world.recorder.events) != 0 {
t.Fatal("a refused call records nothing")
}
})
}
t.Run("API key created by user 5 reads", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raAdmin, "ci")
assertRolesAPIResult(t, world.withKey(t, http.MethodGet, "/v1/role", "", raw), "200")
})
t.Run("API key created by user 5 writes nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raAdmin, "ci")
before := len(world.roles.roles)
for _, call := range []struct{ method, path, body string }{
{http.MethodPost, "/v1/role", `{"name": "Keyed"}`},
{http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "x", 1, rolesAPIContributorRows()...)},
{http.MethodDelete, world.path("Intern"), ""},
{http.MethodPost, world.path("Contributor") + "/reassign", `{"target_role_id": 3}`},
{http.MethodPost, world.path("Writer") + "/reset", `{"version": 1}`},
} {
assertRolesAPIResult(t, world.withKey(t, call.method, call.path, call.body, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
}
if len(world.roles.roles) != before || len(world.recorder.events) != 0 {
t.Fatal("nothing may be stored or recorded")
}
})
t.Run("stale token_version", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.send(t, apiKeyRequest{method: http.MethodGet, path: "/v1/role", cookieUser: raAdmin, cookieVersion: 1})
assertAPIKeyStatus(t, rec, http.StatusUnauthorized)
})
t.Run("a session follows the role live", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "gate:roles.manage")
viewer := world.role("Viewer")
viewer.Grants = append(viewer.Grants, role.Grant{Key: role.KeyRolesManage, Scope: role.ScopeAll})
world.roles.roles[viewer.ID] = viewer
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "201")
})
t.Run("reading one role and /auth/me never List", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), "200")
if world.roles.calls["List"] != 0 {
t.Errorf("reading one role must not List, got %d", world.roles.calls["List"])
}
world.resetCounters()
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/auth/me", ""), "200")
if world.roles.calls["List"] != 0 {
t.Errorf("/auth/me must not List, got %d", world.roles.calls["List"])
}
})
}
func TestRolesAPIReading(t *testing.T) {
t.Run("The list returns every role with grants and member counts", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "application/json") {
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Content-Type"))
}
var listed []rolesAPIBody
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
members := map[string]int{}
for index, item := range listed {
members[item.Name] = item.Members
if index > 0 && listed[index-1].ID >= item.ID {
t.Fatal("the list must be ordered by id")
}
}
if listed[0].Name != "Owner" || listed[1].Name != "Admin" || listed[2].Name != "Writer" || len(listed[0].Grants) != 34 || len(listed[1].Grants) != 29 {
t.Fatalf("unexpected built-ins %+v", listed[:3])
}
for name, count := range map[string]int{"Owner": 1, "Admin": 3, "Writer": 1, "Contributor": 4, "Intern": 0} {
if members[name] != count {
t.Errorf("%s: expected %d members, got %d", name, count, members[name])
}
}
if !strings.Contains(rec.Body.String(), `"updated_by":null`) {
t.Error("a never edited role has a null updated_by")
}
})
t.Run("Reading one role", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.Members != 4 || body.Version != 1 || !reflect.DeepEqual(body.Grants, grantRows(rolesAPIContributorRows()...)) {
t.Fatalf("unexpected role %+v", body)
}
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/role/999", ""), "404 role_not_found")
assertAPIKeyMessage(t, world.do(t, raViewer, http.MethodGet, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
t.Run("A role with a hand-placed owner-only key or duplicate rows is returned normalised", func(t *testing.T) {
world := newRolesAPIWorld(t)
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "system.apply_updates=all")})
body := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodGet, "/v1/role/"+strconv.Itoa(int(hand.ID)), ""), http.StatusOK)
if !reflect.DeepEqual(body.Grants, grantRows("content.update=own")) {
t.Fatalf("unexpected grants %v", body.Grants)
}
})
t.Run("Who saved last", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, rolesAPIContributorRows()...)), "200")
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.UpdatedAt == nil || *body.UpdatedAt != "2026-10-04T12:00:00Z" || body.UpdatedBy == nil || body.UpdatedBy.ID != raAdmin || body.UpdatedBy.Name != world.users.users[raAdmin].Username {
t.Fatalf("unexpected editor %+v at %v", body.UpdatedBy, body.UpdatedAt)
}
delete(world.users.users, raAdmin)
body = decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.UpdatedBy != nil {
t.Fatal("a deleted editor reads as null")
}
})
}
func TestRolesAPICreate(t *testing.T) {
t.Run("Creating the Contributor role (design flow, section 11)", func(t *testing.T) {
world := newRolesAPIWorld(t)
request := `{"name": "Writer plus", "description": "Writes posts and edits only their own", "built_in": true, "locked": true, "grants": [{"key": "content.read", "scope": "all"}, {"key": "content.create", "scope": "all"}, {"key": "content.update", "scope": "own"}, {"key": "schema.view", "scope": "all"}, {"key": "media.view", "scope": "all"}]}`
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
body := decodeRolesAPIBody(t, rec, http.StatusCreated)
if body.BuiltIn || body.Locked || body.Version != 1 || body.Members != 0 || body.Name != "Writer plus" {
t.Fatalf("unexpected role %+v", body)
}
if !reflect.DeepEqual(body.Grants, grantRows("content.read=all", "content.create=all", "content.update=own", "schema.view=all", "media.view=all")) {
t.Fatalf("unexpected grants %v", body.Grants)
}
listed := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if !strings.Contains(listed.Body.String(), `"name":"Writer plus"`) {
t.Fatal("the new role must be listed")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.create" {
t.Fatalf("expected exactly one role.create event, got %v", world.recorder.actions())
}
})
t.Run("A new role may start with nothing granted", func(t *testing.T) {
world := newRolesAPIWorld(t)
for _, request := range []string{`{"name": "Empty"}`, `{"name": "Empty 2", "grants": []}`} {
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"grants":[]`) {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
}
})
validation := []struct {
grants string
want string
}{
{"content.update=all", "201"},
{"content.update:events=own", "201"},
{"content.update:article=none", "201"},
{"content.update:events=all,content.update=own", "201"},
{"content.explode=all", "400 unknown_permission"},
{"schema.view:events=all", "400 unknown_permission"},
{"content.update:Bad Key=all", "400 unknown_permission"},
{"content.update:nope=all", "400 unknown_content_type"},
{"content.update:author=all", "400 unknown_content_type"},
{"content.update=none", "400 invalid_scope"},
{"schema.view=own", "400 invalid_scope"},
{"content.update=banana", "400 invalid_scope"},
{"content.create:events=own", "400 invalid_scope"},
{"settings.delete_site=all", "400 owner_only_permission"},
{"system.apply_updates=all", "400 owner_only_permission"},
{"content.update=all,content.update=own", "400 duplicate_permission"},
{"content.update:events=all,content.update:events=own", "400 duplicate_permission"},
}
for _, c := range validation {
t.Run("Write-time validation of "+c.grants, func(t *testing.T) {
world := newRolesAPIWorld(t)
before := len(world.roles.roles)
request := `{"name": "Probe", "grants": ` + rolesAPIGrantsJSON(strings.Split(c.grants, ",")...) + `}`
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", request), c.want)
if c.want != "201" && (len(world.roles.roles) != before || len(world.recorder.events) != 0) {
t.Fatal("nothing is stored and no audit event is recorded")
}
})
}
t.Run("owner-only keys are refused on a built-in edit too", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := rolesAPIUpdateBody("Admin", "", 1, append(adminRows(), "system.apply_updates=all")...)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Admin"), body), "400 owner_only_permission")
})
longName := strings.Repeat("n", 101)
names := []struct {
name string
request string
want string
storedName string
description string
}{
{"an empty name", `{"name": ""}`, "400", "", ""},
{"a blank name", `{"name": " "}`, "400 invalid_role_name", "", ""},
{"101 characters", `{"name": "` + longName + `"}`, "400", "", ""},
{"100 characters", `{"name": "` + longName[:100] + `"}`, "201", longName[:100], ""},
{"surrounding spaces", `{"name": " Reviewer "}`, "201", "Reviewer", ""},
{"256 characters of description", `{"name": "Reviewer 2", "description": "` + strings.Repeat("d", 256) + `"}`, "400", "", ""},
{"Writer", `{"name": "Writer"}`, "409 role_name_taken", "", ""},
{"WRITER", `{"name": "WRITER"}`, "409 role_name_taken", "", ""},
{"padded contributor", `{"name": " contributor "}`, "409 role_name_taken", "", ""},
}
for _, c := range names {
t.Run("Name and description rules: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", c.request)
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.want == "201" && decodeRolesAPIBody(t, rec, http.StatusCreated).Name != c.storedName {
t.Fatalf("expected the name %q", c.storedName)
}
})
}
t.Run("a malformed body is the body error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": `), http.StatusBadRequest, apperr.BodyErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), `[`), http.StatusBadRequest, apperr.BodyErrorMessage)
})
t.Run("a grant without a key fails the validate tag", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyStatus(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "X", "grants": [{"scope": "all"}]}`), http.StatusBadRequest)
})
t.Run("No-escalation on create", func(t *testing.T) {
world := newRolesAPIWorld(t)
request := func(rows ...string) string {
return `{"name": "Escalating", "grants": ` + rolesAPIGrantsJSON(rows...) + `}`
}
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=all")), "escalation:content.publish")
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish:events=all")), "escalation:content.publish:events")
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=own")), "201")
if len(world.recorder.events) != 1 {
t.Fatalf("only the successful create is recorded, got %v", world.recorder.actions())
}
})
}
func TestRolesAPIEditing(t *testing.T) {
contributor := rolesAPIContributorRows
cases := []struct {
name string
caller uint32
target string
body func(*rolesAPIWorld) string
want string
after func(*testing.T, *rolesAPIWorld, rolesAPIBody)
}{
{"grants plus delete own", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=own")...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 2 || b.Members != 4 || !reflect.DeepEqual(b.Grants, grantRows(contributor("content.delete=own")...)) {
t.Fatalf("unexpected role %+v", b)
}
}},
{"grants minus update", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, "content.read=all", "content.create=all")
}, "200", nil},
{"nothing changed", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, contributor()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 1 || len(w.recorder.events) != 0 {
t.Fatalf("a no-op must not bump the version or record, got %+v", b)
}
}},
{"nothing changed on a stale version", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 7, contributor()...)
}, "409 role_conflict", nil},
{"no version field", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return `{"name": "Contributor", "grants": []}`
}, "400", nil},
{"name taken", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("writer", "", 1, contributor()...)
}, "409 role_name_taken", nil},
{"case-only rename", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("contributor", "", 1, contributor()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Name != "contributor" {
t.Fatalf("expected the name as sent, got %q", b.Name)
}
}},
{"unknown key", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all")
}, "400 unknown_permission", nil},
{"unknown role", raAdmin, "Missing", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("X", "", 1)
}, "404 role_not_found", nil},
{"owner edits the writer description", raOwner, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 2 || !b.BuiltIn {
t.Fatalf("the role keeps built_in, got %+v", b)
}
}},
{"owner renames the writer", raOwner, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Author", "", 1, writerRows()...)
}, "400 built_in_name", nil},
{"admin edits the writer", raAdmin, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
}, "gate:roles.edit_owner_only", nil},
{"admin edits admin", raAdmin, "Admin", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Admin", "d", 1, adminRows()...)
}, "gate:roles.edit_owner_only", nil},
{"owner edits the owner", raOwner, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "", 1) }, "403 role_locked", nil},
{"admin edits the owner", raAdmin, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "x", 1) }, "403 role_locked", nil},
{"role manager edits their own role", raRoleManager, "Role manager", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Role manager", "d", 1)
}, "403 own_role", nil},
}
for _, c := range cases {
t.Run("Editing a role: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPut, world.path(c.target), c.body(world))
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.after != nil {
c.after(t, world, decodeRolesAPIBody(t, rec, http.StatusOK))
}
if c.want != "200" && c.target != "Missing" && world.role(c.target).Version != 1 {
t.Fatal("a refusal bumps no version")
}
})
}
t.Run("Editing a role: a refused call with a stale version still reports the escalation first", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Content lead"), rolesAPIUpdateBody("Content lead", "", 9))
assertRolesAPIResult(t, rec, "escalation")
})
t.Run("Order: an unknown key is a 400 before the escalation", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all", "content.delete=all"))
assertRolesAPIResult(t, rec, "400 unknown_permission")
})
t.Run("Members see the new grants on their next request, with the same cookie and API key", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raContributorOne, "contributor key")
world.seedArticle(1, true)
assertAPIKeyStatus(t, world.do(t, raContributorOne, http.MethodDelete, "/v1/article/1", ""), http.StatusForbidden)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=all")...)), "200")
assertAPIKeyStatus(t, world.withKey(t, http.MethodDelete, "/v1/article/1", "", raw), http.StatusNoContent)
})
t.Run("A stale editor gets a conflict, then reads the new state", func(t *testing.T) {
world := newRolesAPIWorld(t)
first := rolesAPIUpdateBody("Contributor", "from 3", 1, contributor()...)
second := rolesAPIUpdateBody("Contributor", "from 5", 1, contributor()...)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Contributor"), first), "200")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), second), "409 role_conflict")
if stored := world.role("Contributor"); stored.Description != "from 3" || stored.Version != 2 {
t.Fatalf("the stored role must be the first save, got %+v", stored)
}
})
t.Run("A save that loses the race at the repository answers role_conflict", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.beforeSave = func() {
stored := world.role("Contributor")
stored.Version = 2
world.roles.roles[stored.ID] = stored
}
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "409 role_conflict")
})
t.Run("A storage failure at the save is a 500", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Save")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "500 Internal server error")
})
t.Run("A non-numeric id is the id error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, "/v1/role/abc", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role/abc/reassign", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, "/v1/role/abc/reset", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
noEscalation := []struct {
caller uint32
extra []string
want string
}{
{raRoleManager, []string{"content.publish=own"}, "200"},
{raRoleManager, []string{"content.publish=all"}, "escalation:content.publish"},
{raRoleManager, []string{"content.delete=own"}, "escalation:content.delete"},
{raRoleManager, []string{"schema.edit=all"}, "escalation"},
{raRoleManager, []string{"content.publish:events=none"}, "200"},
{raRoleManager, []string{"content.publish:events=all"}, "escalation:content.publish:events"},
{raRoleManager, []string{"content.update:events=own"}, "200"},
{raRoleManager, []string{"content.update:events=all"}, "escalation"},
{raAdmin, []string{"content.delete=all"}, "200"},
{raOwner, []string{"content.delete=all"}, "200"},
}
for _, c := range noEscalation {
t.Run("No-escalation on edit: "+c.extra[0]+" by user "+strconv.Itoa(int(c.caller)), func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor(c.extra...)...))
assertRolesAPIResult(t, rec, c.want)
if c.want != "200" && (world.role("Contributor").Version != 1 || len(world.recorder.events) != 0) {
t.Fatal("a refused call changes no field, bumps no version and records no event")
}
})
}
t.Run("No-escalation with a typed-only reader and a typed none", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raNarrowManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor()...)), "escalation")
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all")), "escalation:content.update:article")
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=none")), "200")
})
t.Run("No-escalation with a typed manager: a typed own above the caller's none is refused", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=own"))
assertRolesAPIResult(t, rec, "escalation:content.update:article")
if world.role("Typed target").Version != 1 || len(world.recorder.events) != 0 {
t.Fatal("a refused call changes no field, bumps no version and records no event")
}
})
above := []struct {
name string
caller uint32
method string
path func(*rolesAPIWorld) string
body func(*rolesAPIWorld) string
want string
}{
{"PUT Content lead by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Content lead", "", 1) }, "escalation"},
{"DELETE Content lead by Role manager", raRoleManager, http.MethodDelete, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return "" }, "escalation"},
{"reassign Content lead by Role manager", raRoleManager, http.MethodPost, func(w *rolesAPIWorld) string { return w.path("Content lead") + "/reassign" }, func(w *rolesAPIWorld) string { return `{"target_role_id": ` + strconv.Itoa(int(w.ids["Intern"])) + `}` }, "escalation"},
{"PUT Contributor with a harmless change by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Contributor") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Contributor", "harmless", 1, contributor()...) }, "200"},
{"PUT Content lead with a harmless change by Admin", raAdmin, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Content lead", "harmless", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all")
}, "200"},
}
for _, c := range above {
t.Run("A caller cannot touch a role above their own grants: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path(world), c.body(world)), c.want)
})
}
}
+332
View File
@@ -0,0 +1,332 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"encoding/json"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
func TestRolesAPIDeleting(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
want string
}{
{"Owner deletes Intern", raOwner, "Intern", "204"},
{"Admin deletes Intern", raAdmin, "Intern", "204"},
{"Admin deletes Contributor", raAdmin, "Contributor", "409 role_has_members"},
{"Owner deletes Writer", raOwner, "Writer", "409 role_built_in"},
{"Owner deletes Admin", raOwner, "Admin", "409 role_built_in"},
{"Owner deletes Owner", raOwner, "Owner", "403 role_locked"},
{"Admin deletes Owner", raAdmin, "Owner", "403 role_locked"},
{"Admin deletes 999", raAdmin, "Missing", "404 role_not_found"},
{"Role manager deletes Role manager", raRoleManager, "Role manager", "403 own_role"},
{"Role manager deletes Content lead", raRoleManager, "Content lead", "escalation"},
{"Role manager deletes Intern", raRoleManager, "Intern", "204"},
}
for _, c := range rows {
t.Run("Deleting a role: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodDelete, world.path(c.target), "")
assertRolesAPIResult(t, rec, c.want)
_, exists := world.roles.roles[world.ids[c.target]]
if c.want == "204" {
if exists || rec.Body.Len() != 0 {
t.Fatal("the role must be gone and the body empty")
}
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodGet, world.path(c.target), ""), "404 role_not_found")
return
}
if c.target != "Missing" && !exists {
t.Fatal("a refused delete deletes nothing")
}
})
}
t.Run("a delete never touches content", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.seedArticle(7, true)
article := world.articles.articles[7]
article.CreatedBy = raContributorOne
world.articles.articles[7] = article
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "204")
if world.articles.articles[7].CreatedBy != raContributorOne {
t.Fatal("content keeps its created_by")
}
})
t.Run("a member appearing meanwhile answers role_has_members", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNextWith("Delete", role.ErrRoleHasMembers)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "409 role_has_members")
})
}
func TestRolesAPIReassigning(t *testing.T) {
target := func(w *rolesAPIWorld, name string) string {
return `{"target_role_id": ` + strconv.Itoa(int(w.ids[name])) + `}`
}
rows := []struct {
name string
caller uint32
source string
body func(*rolesAPIWorld) string
want string
}{
{"Admin moves Contributor to Writer", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
{"Admin moves Intern to Writer", raAdmin, "Intern", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
{"Role manager moves to Admin", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Admin") }, "escalation"},
{"Role manager moves to Intern", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "200"},
{"Admin moves to Owner", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Owner") }, "400 invalid_reassign"},
{"Admin moves to the same role", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Contributor") }, "400 invalid_reassign"},
{"Admin moves to 999", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{"target_role_id": 999}` }, "400 invalid_reassign"},
{"Admin sends no target", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{}` }, "400"},
{"Admin moves from Writer", raAdmin, "Writer", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "409 role_built_in"},
{"Admin moves from Owner", raAdmin, "Owner", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 role_locked"},
{"Admin moves from 999", raAdmin, "Missing", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "404 role_not_found"},
{"Role manager moves from their own role", raRoleManager, "Role manager", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 own_role"},
}
for _, c := range rows {
t.Run("Moving members: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPost, world.path(c.source)+"/reassign", c.body(world))
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.want != "200" && len(world.members("Contributor")) != 4 {
t.Fatal("nobody may be moved by a refusal")
}
})
}
t.Run("Moving members, then deleting", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer"))
var moved struct {
Moved int `json:"moved"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &moved); err != nil || rec.Code != http.StatusOK || moved.Moved != 4 {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if got := world.members("Writer"); !reflect.DeepEqual(got, []uint32{raWriter, 41, 42, 43, 44}) {
t.Fatalf("unexpected Writer members %v", got)
}
account := world.users.users[41]
if account.TokenVersion != 0 || account.Password != "" || account.IsVerified != true {
t.Fatalf("only role_id may change: %+v", account)
}
assertRolesAPIResult(t, world.do(t, 41, http.MethodGet, "/v1/auth/me", ""), "200")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Contributor"), ""), "204")
actions := world.recorder.actions()
if !reflect.DeepEqual(actions, []string{"role.reassign", "role.delete"}) {
t.Fatalf("unexpected events %v", actions)
}
})
t.Run("Moving nobody records no event", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPost, world.path("Intern")+"/reassign", target(world, "Writer"))
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"moved":0}` || len(world.recorder.events) != 0 {
t.Fatalf("unexpected %d %q events=%d", rec.Code, rec.Body.String(), len(world.recorder.events))
}
})
t.Run("A repository failure moves nobody, answers 500 and records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("ReassignMembers")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer")), "500 Internal server error")
if len(world.members("Contributor")) != 4 || len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed {
t.Fatalf("unexpected state, events=%+v", world.recorder.events)
}
})
}
func TestRolesAPIResetting(t *testing.T) {
edited := func(t *testing.T, world *rolesAPIWorld, name string) {
t.Helper()
body := rolesAPIUpdateBody(name, "custom", 1, "content.read=all")
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path(name), body), "200")
world.recorder.events = nil
}
for _, name := range []string{"Writer", "Admin"} {
t.Run("Resetting a built-in role to its defaults: Owner resets "+name+", edited", func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, name)
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path(name)+"/reset", `{"version": 2}`), http.StatusOK)
want := role.Role{Grants: role.BuiltInRoles()[map[string]int{"Admin": 1, "Writer": 2}[name]].Grants}.EffectiveGrants()
if body.Version != 3 || body.Name != name || !body.BuiltIn || body.Locked || body.Description != "" || !reflect.DeepEqual(body.Grants, want) {
t.Fatalf("unexpected role %+v", body)
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.edit" || world.recorder.events[0].Target.Name != name {
t.Fatalf("expected one role.edit event, got %+v", world.recorder.events)
}
})
}
t.Run("Resetting a built-in role to its defaults: a pristine role is a no-op, then a second reset too", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 1}`), http.StatusOK)
if body.Version != 1 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected %+v", body)
}
edited(t, world, "Writer")
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
again := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 3}`), http.StatusOK)
if again.Version != 3 {
t.Fatalf("a second reset is a no-op, got version %d", again.Version)
}
})
t.Run("Resetting a built-in role to its defaults: members see the seeded grants on their next request", func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, "Writer")
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusForbidden)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusOK)
})
refusals := []struct {
name string
caller uint32
target string
version string
want string
}{
{"Owner, Writer, stale", raOwner, "Writer", "1", "409 role_conflict"},
{"Owner, Owner", raOwner, "Owner", "1", "403 role_locked"},
{"Owner, Contributor", raOwner, "Contributor", "1", "400 not_built_in"},
{"Owner, 999", raOwner, "Missing", "1", "404 role_not_found"},
{"Admin, Writer", raAdmin, "Writer", "2", "gate:roles.edit_owner_only"},
{"Viewer, Writer", raViewer, "Writer", "2", "gate:roles.manage"},
}
for _, c := range refusals {
t.Run("Resetting a built-in role to its defaults: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, "Writer")
assertRolesAPIResult(t, world.do(t, c.caller, http.MethodPost, world.path(c.target)+"/reset", `{"version": `+c.version+`}`), c.want)
if len(world.recorder.events) != 0 {
t.Fatal("a refused reset records nothing")
}
})
}
t.Run("Resetting a built-in role to its defaults: a missing version is a validation error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyStatus(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{}`), http.StatusBadRequest)
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{`), http.StatusBadRequest, apperr.BodyErrorMessage)
})
t.Run("Resetting a built-in role to its defaults: the API key of the Owner is refused", func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, "Writer")
raw, _ := world.issueKey(raOwner, "owner key")
assertRolesAPIResult(t, world.withKey(t, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
})
}
func TestRolesAPIActivity(t *testing.T) {
t.Run("Activity events for roles", func(t *testing.T) {
world := newRolesAPIWorld(t)
created := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "Reviewer", "grants": `+rolesAPIGrantsJSON("content.read=all", "content.update=own", "media.view=all")+`}`), http.StatusCreated)
path := "/v1/role/" + strconv.Itoa(int(created.ID))
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, path, rolesAPIUpdateBody("Reviewers", "", 1, "content.read=all", "content.update=all", "content.delete=own")), "200")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, path, ""), "204")
events := world.recorder.events
if !reflect.DeepEqual(world.recorder.actions(), []string{"role.create", "role.edit", "role.delete"}) {
t.Fatalf("unexpected events %v", world.recorder.actions())
}
for _, event := range events {
if event.Category != audit.CategoryRoles || event.Outcome != audit.OutcomeSuccess || event.Target.Type != "role" || event.Target.ID != strconv.Itoa(int(created.ID)) {
t.Fatalf("unexpected event %+v", event)
}
}
edit := events[1]
if edit.Target.Name != "Reviewers" ||
!reflect.DeepEqual(edit.Params[audit.ParamAdded], grantRows("content.delete=own")) ||
!reflect.DeepEqual(edit.Params[audit.ParamRemoved], grantRows("media.view=all")) ||
!reflect.DeepEqual(edit.Params[audit.ParamChanged], []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
t.Fatalf("unexpected edit params %v", edit.Params)
}
if events[2].Params[audit.ParamMembers] != 0 || events[2].Target.Name != "Reviewers" {
t.Fatalf("unexpected delete event %+v", events[2])
}
})
}
func TestRolesAPIAuthMe(t *testing.T) {
t.Run("/auth/me exposes typed grants", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec, body := world.me(t, raEventsEditor)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d", rec.Code)
}
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
if !reflect.DeepEqual(body.Access.Permissions, want) {
t.Fatalf("expected %v, got %v", want, body.Access.Permissions)
}
})
}
func TestRolesAPIContentTypes(t *testing.T) {
createEvents := `{"key": "events", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`
t.Run("Deleting a content type removes its typed grants", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), "204")
editor := world.role("Events editor")
if !reflect.DeepEqual(editor.Grants, grantRows("content.read=all", "content.update:venues=all")) || editor.Version != 2 {
t.Fatalf("unexpected role %v version %d", editor.Grants, editor.Version)
}
if world.role("Contributor").Version != 1 {
t.Fatal("a role without typed rows keeps its version")
}
_, body := world.me(t, raEventsEditor)
if !reflect.DeepEqual(body.Access.Permissions, grantRows("content.read=all", "content.update:venues=all")) {
t.Fatalf("unexpected permissions %v", body.Access.Permissions)
}
})
t.Run("A refused content type delete keeps the typed grants", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.entries.entries[1] = entryOfType(raEventsTypeID)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), http.StatusConflict, apperr.ContentTypeHasEntriesErrorMessage)
if world.role("Events editor").Version != 1 {
t.Fatal("the role must be unchanged")
}
})
t.Run("A content type created with the key of a deleted one starts clean", func(t *testing.T) {
world := newRolesAPIWorld(t)
delete(world.types.types, raEventsTypeID)
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.update:events=none")})
rec := world.do(t, raAdmin, http.MethodPost, "/v1/content-type", createEvents)
if rec.Code != http.StatusCreated {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if len(world.roles.roles[stale.ID].Grants) != 0 {
t.Fatal("the stale row must be removed")
}
})
t.Run("Content type keys do not change", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPut, "/v1/content-type/events", `{"key": "other", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.types.types[raEventsTypeID].Key != "events" || world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
t.Fatal("an update never rewrites the key nor the typed rows")
}
})
}
+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)
}
}
})
}
+252
View File
@@ -0,0 +1,252 @@
package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"reflect"
"sync"
"testing"
"time"
)
func rolesAPIGrantKeys(grants []role.Grant) []string {
keys := []string{}
for _, grant := range grants {
keys = append(keys, grant.Key+"="+string(grant.Scope))
}
return keys
}
func rolesAPICreateRole(t *testing.T, repo *persistence.RoleRepository, name string, rows ...string) role.Role {
t.Helper()
created, err := repo.Create(role.Role{Name: name, Version: 1, Grants: grantRows(rows...)})
if err != nil {
t.Fatal(err)
}
return created
}
func TestRoleRepositoryRolesAPI(t *testing.T) {
t.Run("List returns the grants of every role in one pass", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
rolesAPICreateRole(t, repo, "Contributor", "content.read=all", "content.update:events=own")
listed, err := repo.List()
if err != nil {
t.Fatal(err)
}
if len(listed) != 4 {
t.Fatalf("expected 4 roles, got %d", len(listed))
}
for index, expected := range []int{34, 29, 9, 2} {
if len(listed[index].Grants) != expected {
t.Errorf("role %s: expected %d grants, got %d", listed[index].Name, expected, len(listed[index].Grants))
}
}
})
t.Run("seeded roles have no editor and no edit time", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
listed, _ := repo.List()
for _, seeded := range listed {
if seeded.UpdatedBy != nil || !seeded.UpdatedAt.IsZero() {
t.Errorf("role %s: unexpected editor %v at %v", seeded.Name, seeded.UpdatedBy, seeded.UpdatedAt)
}
}
})
t.Run("Save writes the editor and the time and bumps the version", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
editor := uint32(5)
edited := created
edited.Description = "changed"
edited.UpdatedAt = time.Date(2026, time.October, 5, 11, 2, 0, 0, time.UTC)
edited.UpdatedBy = &editor
saved, err := repo.Save(edited)
if err != nil {
t.Fatal(err)
}
if saved.Version != 2 || saved.UpdatedBy == nil || *saved.UpdatedBy != 5 || !saved.UpdatedAt.Equal(edited.UpdatedAt) {
t.Fatalf("unexpected saved role %+v", saved)
}
})
t.Run("Save is a compare-and-swap on the version", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
first := created
first.Description = "first"
second := created
second.Description = "second"
if _, err := repo.Save(first); err != nil {
t.Fatal(err)
}
if _, err := repo.Save(second); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("expected ErrRoleConflict, got %v", err)
}
stored, _ := repo.Get(created.ID)
if stored.Description != "first" || stored.Version != 2 {
t.Fatalf("unexpected stored role %+v", stored)
}
})
t.Run("two concurrent saves of version 1 give exactly one winner", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
results := make([]error, 2)
var group sync.WaitGroup
for index := range results {
group.Add(1)
go func() {
defer group.Done()
attempt := created
attempt.Description = []string{"a", "b"}[index]
_, results[index] = repo.Save(attempt)
}()
}
group.Wait()
successes := 0
for _, err := range results {
if err == nil {
successes++
} else if !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("unexpected error %v", err)
}
}
if successes != 1 {
t.Fatalf("expected exactly one winner, got %d", successes)
}
})
t.Run("Delete removes the role and its grants", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
rowsBefore := countRows(t, db, &models.RolePermission{})
if err := repo.Delete(created.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.Get(created.ID); !errors.Is(err, role.ErrRoleNotFound) {
t.Fatalf("expected the role to be gone, got %v", err)
}
if countRows(t, db, &models.RolePermission{}) != rowsBefore-1 {
t.Error("the grant rows were not removed")
}
})
t.Run("Delete refuses a role that still has members", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
createUserRow(t, db, 1, "", &created.ID)
if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
}
if _, err := repo.Get(created.ID); err != nil {
t.Fatalf("the role must still exist: %v", err)
}
})
t.Run("Delete reports a missing role", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
if err := repo.Delete(9999); !errors.Is(err, role.ErrRoleNotFound) {
t.Fatalf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("ReassignMembers counts the moved users and touches only role_id", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
writer := storedRole(t, db, "Writer")
createUserRow(t, db, 1, "", &source.ID)
createUserRow(t, db, 2, "", &source.ID)
createUserRow(t, db, 3, "", &writer.ID)
before := userRow(t, db, 1)
moved, err := repo.ReassignMembers(source.ID, writer.ID)
if err != nil || moved != 2 {
t.Fatalf("expected 2 moved, got %d %v", moved, err)
}
after := userRow(t, db, 1)
if roleIDOf(after) != writer.ID {
t.Errorf("user 1 was not moved")
}
after.RoleID, before.RoleID = nil, nil
if !reflect.DeepEqual(before, after) {
t.Errorf("only role_id may change: %+v vs %+v", before, after)
}
if moved, _ := repo.ReassignMembers(source.ID, writer.ID); moved != 0 {
t.Errorf("a second move must move nobody, got %d", moved)
}
})
t.Run("RemoveTypedGrants removes the five typed forms of one type only and bumps affected roles once", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
affected := rolesAPICreateRole(t, repo, "Events editor", "content.read=all", "content.update:events=own", "content.delete:events=none", "content.read:events=all", "content.create:events=all", "content.publish:events=own", "content.update:venues=all")
untouched := rolesAPICreateRole(t, repo, "Venues only", "content.update:venues=all", "content.read=all")
removed, err := repo.RemoveTypedGrants("events")
if err != nil || removed != 1 {
t.Fatalf("expected 1 affected role, got %d %v", removed, err)
}
stored, _ := repo.Get(affected.ID)
if !reflect.DeepEqual(rolesAPIGrantKeys(stored.Grants), []string{"content.read=all", "content.update:venues=all"}) || stored.Version != 2 {
t.Fatalf("unexpected affected role %v version %d", rolesAPIGrantKeys(stored.Grants), stored.Version)
}
other, _ := repo.Get(untouched.ID)
if other.Version != 1 || len(other.Grants) != 2 {
t.Fatalf("the other role must be untouched: %+v", other)
}
if again, _ := repo.RemoveTypedGrants("events"); again != 0 {
t.Errorf("a second removal must affect nothing, got %d", again)
}
})
t.Run("a content type key that prefixes another is not matched", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
kept := rolesAPICreateRole(t, repo, "Long key", "content.update:events_extra=all")
if removed, _ := repo.RemoveTypedGrants("events"); removed != 0 {
t.Fatalf("expected nothing removed, got %d", removed)
}
stored, _ := repo.Get(kept.ID)
if len(stored.Grants) != 1 {
t.Fatal("the grant of events_extra must be kept")
}
})
t.Run("the new methods fail on a broken database", func(t *testing.T) {
for name, call := range map[string]func(*persistence.RoleRepository) error{
"Delete": func(r *persistence.RoleRepository) error { return r.Delete(4) },
"ReassignMembers": func(r *persistence.RoleRepository) error { _, err := r.ReassignMembers(4, 3); return err },
"RemoveTypedGrants": func(r *persistence.RoleRepository) error { _, err := r.RemoveTypedGrants("events"); return err },
} {
db := openMigratedRolesDB(t)
for _, table := range []string{"role_permissions", "users"} {
if err := db.Migrator().DropTable(table); err != nil {
t.Fatal(err)
}
}
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if err := call(persistence.NewRoleRepository(db)); err == nil || errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("%s: expected a storage failure, got %v", name, err)
}
}
})
t.Run("the role columns exist after Migrate", func(t *testing.T) {
db := openMigratedRolesDB(t)
for _, column := range []string{"updated_at", "updated_by"} {
if !db.Migrator().HasColumn(&models.Role{}, column) {
t.Errorf("roles.%s is missing", column)
}
}
})
}
+887
View File
@@ -0,0 +1,887 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"reflect"
"strconv"
"testing"
)
func (w *rolesAPIWorld) update(caller uint32, name string, roleName string, description string, version uint32, rows ...string) (useCases.RoleView, error) {
return w.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{
Caller: w.caller(caller), ID: w.ids[roleName], Name: name, Description: description, Version: version, Grants: grantRows(rows...),
})
}
func (w *rolesAPIWorld) create(caller uint32, name string, rows ...string) (useCases.RoleView, error) {
return w.container.CreateRoleUseCase.Create(context.Background(), useCases.CreateRoleCommand{Caller: w.caller(caller), Name: name, Grants: grantRows(rows...)})
}
func (w *rolesAPIWorld) remove(caller uint32, roleName string) error {
return w.container.DeleteRoleUseCase.Delete(context.Background(), useCases.DeleteRoleCommand{Caller: w.caller(caller), ID: w.ids[roleName]})
}
func (w *rolesAPIWorld) reassign(caller uint32, source string, targetID uint32) (int, error) {
return w.container.ReassignRoleMembersUseCase.Reassign(context.Background(), useCases.ReassignRoleMembersCommand{Caller: w.caller(caller), SourceID: w.ids[source], TargetID: targetID})
}
func requireEscalationKey(t *testing.T, err error, key string) {
t.Helper()
if got := escalationKeyOf(t, err); key != "" && got != key {
t.Fatalf("expected the escalation key %q, got %q", key, got)
}
}
func TestListAndGetRolesUseCases(t *testing.T) {
t.Run("the list holds every role by id with members counted through the legacy fallback", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
listed, err := world.container.ListRolesUseCase.List()
if err != nil {
t.Fatal(err)
}
if world.roles.calls["List"] != 1 {
t.Errorf("expected one List call, got %d", world.roles.calls["List"])
}
got := map[string]int{}
for index, item := range listed {
got[item.Name] = item.Members
if index > 0 && listed[index-1].ID >= item.ID {
t.Errorf("the list must be ordered by id")
}
}
for name, members := range map[string]int{"Owner": 1, "Admin": 3, "Writer": 1, "Contributor": 4, "Intern": 0, "Content lead": 1} {
if got[name] != members {
t.Errorf("%s: expected %d members, got %d", name, members, got[name])
}
}
if len(listed[0].Grants) != 34 || len(listed[1].Grants) != 29 {
t.Errorf("expected 34 and 29 grants, got %d and %d", len(listed[0].Grants), len(listed[1].Grants))
}
})
t.Run("a hand-placed owner-only key and duplicate rows are returned normalised", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "system.apply_updates=all")})
found, err := world.container.GetRoleUseCase.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(found.Grants, grantRows("content.update=own")) {
t.Fatalf("unexpected grants %v", found.Grants)
}
})
t.Run("reading one role reports members, version and a missing role", func(t *testing.T) {
world := newRolesAPIWorld(t)
found, err := world.container.GetRoleUseCase.Get(world.ids["Contributor"])
if err != nil || found.Members != 4 || found.Version != 1 {
t.Fatalf("unexpected role %+v %v", found, err)
}
if _, err := world.container.GetRoleUseCase.Get(raUnknownRoleID); !errors.Is(err, role.ErrRoleNotFound) {
t.Fatalf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("updated_by names the display name, else the username, and is null for a deleted user", func(t *testing.T) {
world := newRolesAPIWorld(t)
editor := raAdmin
stored := world.role("Contributor")
stored.UpdatedAt = world.now
stored.UpdatedBy = &editor
world.roles.roles[stored.ID] = stored
found, _ := world.container.GetRoleUseCase.Get(stored.ID)
if found.UpdatedBy == nil || found.UpdatedBy.ID != raAdmin || found.UpdatedBy.Name != world.users.users[raAdmin].Username {
t.Fatalf("expected the username, got %+v", found.UpdatedBy)
}
if found.UpdatedAt == nil || !found.UpdatedAt.Equal(world.now) {
t.Fatalf("unexpected updated_at %v", found.UpdatedAt)
}
named := world.users.users[raAdmin]
named.DisplayName = " Marta "
world.users.users[raAdmin] = named
found, _ = world.container.GetRoleUseCase.Get(stored.ID)
if found.UpdatedBy.Name != "Marta" {
t.Fatalf("expected the display name, got %q", found.UpdatedBy.Name)
}
delete(world.users.users, raAdmin)
found, _ = world.container.GetRoleUseCase.Get(stored.ID)
if found.UpdatedBy != nil {
t.Fatalf("expected a null editor, got %+v", found.UpdatedBy)
}
})
t.Run("a role never edited has no editor and no time", func(t *testing.T) {
world := newRolesAPIWorld(t)
found, _ := world.container.GetRoleUseCase.Get(world.ids["Contributor"])
if found.UpdatedBy != nil || found.UpdatedAt != nil {
t.Fatalf("expected null editor and time, got %+v %v", found.UpdatedBy, found.UpdatedAt)
}
})
t.Run("a storage failure is returned", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("List")
if _, err := world.container.ListRolesUseCase.List(); err == nil {
t.Fatal("expected an error")
}
world.roles.fail.failNext("Get")
if _, err := world.container.GetRoleUseCase.Get(world.ids["Contributor"]); err == nil {
t.Fatal("expected an error")
}
})
}
func TestCreateRoleUseCase(t *testing.T) {
validation := []struct {
name string
rows []string
want error
typed bool
}{
{"content.update=all", []string{"content.update=all"}, nil, false},
{"content.update:events=own", []string{"content.update:events=own"}, nil, false},
{"content.update:article=none", []string{"content.update:article=none"}, nil, false},
{"typed plus wildcard", []string{"content.update:events=all", "content.update=own"}, nil, false},
{"content.explode=all", []string{"content.explode=all"}, role.ErrUnknownPermission, false},
{"schema.view:events=all", []string{"schema.view:events=all"}, role.ErrUnknownPermission, false},
{"content.update:Bad Key=all", []string{"content.update:Bad Key=all"}, role.ErrUnknownPermission, false},
{"content.update:nope=all", []string{"content.update:nope=all"}, role.ErrUnknownContentType, false},
{"content.update:author=all", []string{"content.update:author=all"}, role.ErrUnknownContentType, false},
{"content.update=none", []string{"content.update=none"}, role.ErrInvalidScope, false},
{"schema.view=own", []string{"schema.view=own"}, role.ErrInvalidScope, false},
{"content.update=banana", []string{"content.update=banana"}, role.ErrInvalidScope, false},
{"content.create:events=own", []string{"content.create:events=own"}, role.ErrInvalidScope, false},
{"settings.delete_site=all", []string{"settings.delete_site=all"}, role.ErrOwnerOnlyGrant, false},
{"system.apply_updates=all", []string{"system.apply_updates=all"}, role.ErrOwnerOnlyGrant, false},
{"duplicate wildcard", []string{"content.update=all", "content.update=own"}, role.ErrDuplicateGrant, false},
{"duplicate typed", []string{"content.update:events=all", "content.update:events=own"}, role.ErrDuplicateGrant, false},
}
for _, c := range validation {
t.Run("Admin creating a role with "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
before := len(world.roles.roles)
created, err := world.create(raAdmin, "Probe", c.rows...)
if !errors.Is(err, c.want) || (c.want == nil && err != nil) {
t.Fatalf("expected %v, got %v", c.want, err)
}
if c.want != nil {
if len(world.roles.roles) != before || len(world.recorder.events) != 0 {
t.Fatalf("a refused create must store nothing and record nothing")
}
return
}
if created.BuiltIn || created.Locked || created.Version != 1 || created.Members != 0 {
t.Fatalf("unexpected role %+v", created)
}
if len(world.recorder.events) != 1 {
t.Fatalf("expected one event, got %d", len(world.recorder.events))
}
})
}
t.Run("the grants come back in catalogue order and a client cannot set the flags", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.container.CreateRoleUseCase.Create(context.Background(), useCases.CreateRoleCommand{
Caller: world.caller(raAdmin), Name: "Writer plus", Description: "Writes", Grants: grantRows("media.view=all", "content.update=own", "content.read=all"),
})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(created.Grants, grantRows("content.read=all", "content.update=own", "media.view=all")) {
t.Fatalf("unexpected grants %v", created.Grants)
}
stored := world.roles.roles[created.ID]
if stored.BuiltIn || stored.Locked || stored.Version != 1 || stored.UpdatedBy != nil {
t.Fatalf("unexpected stored role %+v", stored)
}
})
t.Run("a new role may start with nothing granted", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.create(raAdmin, "Empty")
if err != nil || created.Grants == nil || len(created.Grants) != 0 {
t.Fatalf("expected empty grants, got %+v %v", created.Grants, err)
}
})
t.Run("names are trimmed, blank ones refused and duplicates found case-insensitively", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.create(raAdmin, " Reviewer ")
if err != nil || created.Name != "Reviewer" {
t.Fatalf("expected the trimmed name, got %q %v", created.Name, err)
}
if _, err := world.create(raAdmin, " "); !errors.Is(err, role.ErrInvalidRoleName) {
t.Fatalf("expected ErrInvalidRoleName, got %v", err)
}
for _, name := range []string{"Writer", "WRITER", " contributor ", "reviewer"} {
if _, err := world.create(raAdmin, name); !errors.Is(err, role.ErrRoleNameTaken) {
t.Errorf("%q: expected ErrRoleNameTaken, got %v", name, err)
}
}
})
t.Run("no-escalation applies to a create", func(t *testing.T) {
world := newRolesAPIWorld(t)
cases := []struct {
caller uint32
rows []string
key string
}{
{raRoleManager, []string{"content.publish=all"}, "content.publish"},
{raRoleManager, []string{"content.delete=own"}, "content.delete"},
{raRoleManager, []string{"schema.edit=all"}, "schema.edit"},
{raRoleManager, []string{"content.publish:events=all"}, "content.publish:events"},
{raTypedManager, []string{"content.update=all"}, "content.update:article"},
}
for _, c := range cases {
_, err := world.create(c.caller, "Escalating", c.rows...)
requireEscalationKey(t, err, c.key)
}
for _, c := range []struct {
caller uint32
rows []string
}{
{raRoleManager, []string{"content.publish=own"}},
{raRoleManager, []string{"content.publish:events=none"}},
{raOwner, []string{"content.delete=all"}},
} {
name := "Fine " + c.rows[0] + string(rune('a'+c.caller))
if _, err := world.create(c.caller, name, c.rows...); err != nil {
t.Errorf("%v: expected success, got %v", c.rows, err)
}
}
})
t.Run("the order of checks is grants, covers, then the name", func(t *testing.T) {
world := newRolesAPIWorld(t)
if _, err := world.create(raRoleManager, "Writer", "content.explode=all"); !errors.Is(err, role.ErrUnknownPermission) {
t.Fatalf("expected the validation first, got %v", err)
}
if _, err := world.create(raRoleManager, "Writer", "content.publish=all"); !errors.Is(err, role.ErrEscalation) {
t.Fatalf("expected the escalation before the name, got %v", err)
}
})
t.Run("the create event carries the target and every granted entry", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, _ := world.create(raAdmin, "Reviewer", "content.read=all", "content.update=own")
event := world.recorder.events[0]
if event.Action != "role.create" || event.Category != audit.CategoryRoles || event.Outcome != audit.OutcomeSuccess {
t.Fatalf("unexpected event %+v", event)
}
if event.Target.Type != "role" || event.Target.Name != "Reviewer" || event.Target.ID != strconv.FormatUint(uint64(created.ID), 10) {
t.Fatalf("unexpected target %+v", event.Target)
}
if !reflect.DeepEqual(event.Params[audit.ParamAdded], created.Grants) {
t.Fatalf("unexpected added %v", event.Params[audit.ParamAdded])
}
})
t.Run("a storage failure records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Create")
if _, err := world.create(raAdmin, "Reviewer"); err == nil {
t.Fatal("expected an error")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].FailureReason == nil {
t.Fatalf("expected one failed event, got %+v", world.recorder.events)
}
})
}
func TestUpdateRoleUseCase(t *testing.T) {
contributor := rolesAPIContributorRows
t.Run("editing a role", func(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
newName string
description string
version uint32
grants []string
wantErr error
wantVersion uint32
}{
{"grants plus delete own", raAdmin, "Contributor", "Contributor", "", 1, contributor("content.delete=own"), nil, 2},
{"grants minus update", raAdmin, "Contributor", "Contributor", "", 1, []string{"content.read=all", "content.create=all"}, nil, 2},
{"description changed", raAdmin, "Contributor", "Contributor", "new", 1, contributor(), nil, 2},
{"nothing changed", raAdmin, "Contributor", "Contributor", "", 1, contributor(), nil, 1},
{"nothing changed on a stale version", raAdmin, "Contributor", "Contributor", "", 7, contributor(), role.ErrRoleConflict, 1},
{"body changed on a stale version", raAdmin, "Contributor", "Contributor", "x", 7, contributor(), role.ErrRoleConflict, 1},
{"name taken", raAdmin, "Contributor", "writer", "", 1, contributor(), role.ErrRoleNameTaken, 1},
{"case-only rename", raAdmin, "Contributor", "contributor", "", 1, contributor(), nil, 2},
{"name of another custom role", raAdmin, "Contributor", "Intern", "", 1, contributor(), role.ErrRoleNameTaken, 1},
{"blank name", raAdmin, "Contributor", " ", "", 1, contributor(), role.ErrInvalidRoleName, 1},
{"unknown key", raAdmin, "Contributor", "Contributor", "", 1, []string{"content.explode=all"}, role.ErrUnknownPermission, 1},
{"owner edits the writer description", raOwner, "Writer", "Writer", "d", 1, writerRows(), nil, 2},
{"owner adds media.delete=own to the writer", raOwner, "Writer", "Writer", "", 1, append(writerRows(), "media.delete=own"), nil, 2},
{"owner renames a built-in", raOwner, "Writer", "Author", "", 1, writerRows(), role.ErrBuiltInName, 1},
{"owner adds an owner-only key to admin", raOwner, "Admin", "Admin", "", 1, append(adminRows(), "system.apply_updates=all"), role.ErrOwnerOnlyGrant, 1},
{"owner removes users.remove from admin", raOwner, "Admin", "Admin", "", 1, withoutRow(adminRows(), "users.remove=all"), nil, 2},
{"owner edits the owner", raOwner, "Owner", "Owner", "", 1, nil, role.ErrRoleLocked, 1},
{"admin edits the owner", raAdmin, "Owner", "Owner", "", 1, nil, role.ErrRoleLocked, 1},
{"admin edits the writer", raAdmin, "Writer", "Writer", "d", 1, writerRows(), role.ErrMissingPermission, 1},
{"admin edits their own built-in role", raAdmin, "Admin", "Admin", "d", 1, adminRows(), role.ErrMissingPermission, 1},
{"role manager edits their own role", raRoleManager, "Role manager", "Role manager", "d", 1, nil, role.ErrOwnRole, 1},
{"unknown role", raAdmin, "Missing", "X", "", 1, nil, role.ErrRoleNotFound, 1},
}
for _, c := range rows {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
updated, err := world.update(c.caller, c.newName, c.target, c.description, c.version, c.grants...)
if c.wantErr != nil {
if !errors.Is(err, c.wantErr) {
t.Fatalf("expected %v, got %v", c.wantErr, err)
}
if len(world.recorder.events) != 0 {
t.Fatalf("a refusal must record nothing")
}
if c.target != "Missing" && world.role(c.target).Version != 1 {
t.Fatalf("a refusal must not bump the version")
}
return
}
if err != nil || updated.Version != c.wantVersion {
t.Fatalf("expected version %d, got %+v %v", c.wantVersion, updated, err)
}
if updated.ID != world.ids[c.target] || updated.BuiltIn != world.role(c.target).BuiltIn || updated.Locked {
t.Fatalf("id and flags must be unchanged: %+v", updated)
}
})
}
})
t.Run("a missing-permission refusal names roles.edit_owner_only", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raAdmin, "Writer", "Writer", "d", 1, writerRows()...)
requireMissingKey(t, err, role.KeyRolesEditOwnerOnly)
})
t.Run("no-escalation on edit", func(t *testing.T) {
cases := []struct {
caller uint32
extra []string
wantKey string
}{
{raRoleManager, []string{"content.publish=own"}, ""},
{raRoleManager, []string{"content.publish=all"}, "content.publish"},
{raRoleManager, []string{"content.delete=own"}, "content.delete"},
{raRoleManager, []string{"schema.edit=all"}, "schema.edit"},
{raRoleManager, []string{"content.publish:events=none"}, ""},
{raRoleManager, []string{"content.publish:events=all"}, "content.publish:events"},
{raRoleManager, []string{"content.update:events=own"}, ""},
{raRoleManager, []string{"content.update:events=all"}, "content.update:events"},
{raAdmin, []string{"content.delete=all"}, ""},
{raOwner, []string{"content.delete=all"}, ""},
}
for _, c := range cases {
t.Run(c.extra[0]+" by "+string(rune('A'+c.caller)), func(t *testing.T) {
world := newRolesAPIWorld(t)
before := world.role("Contributor")
_, err := world.update(c.caller, "Contributor", "Contributor", "", 1, contributor(c.extra...)...)
if c.wantKey == "" {
if err != nil {
t.Fatalf("expected success, got %v", err)
}
return
}
requireEscalationKey(t, err, c.wantKey)
if !reflect.DeepEqual(world.role("Contributor"), before) || len(world.recorder.events) != 0 {
t.Fatalf("a refused call changes nothing and records nothing")
}
})
}
})
t.Run("a typed-all wildcard raise is an escalation on the article, a typed none is not", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all")
requireEscalationKey(t, err, "content.update:article")
_, err = world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=own")
requireEscalationKey(t, err, "content.update:article")
if _, err = world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=none"); err != nil {
t.Fatalf("expected success, got %v", err)
}
})
t.Run("the existing role must be covered by the caller", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raNarrowManager, "Contributor", "Contributor", "", 1, contributor()...)
requireEscalationKey(t, err, "content.read")
_, err = world.update(raRoleManager, "Content lead", "Content lead", "", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all")
requireEscalationKey(t, err, "content.update")
if _, err = world.update(raAdmin, "Content lead", "Content lead", "d", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all"); err != nil {
t.Fatalf("expected success, got %v", err)
}
if _, err = world.update(raRoleManager, "Contributor", "Contributor", "harmless", 1, contributor()...); err != nil {
t.Fatalf("expected success, got %v", err)
}
})
t.Run("the order of checks is observable through the fake counters", func(t *testing.T) {
refused := []struct {
name string
caller uint32
target string
desc string
want error
}{
{"locked", raAdmin, "Owner", "", role.ErrRoleLocked},
{"built-in", raAdmin, "Writer", "d", role.ErrMissingPermission},
{"own role", raRoleManager, "Role manager", "d", role.ErrOwnRole},
}
for _, c := range refused {
world := newRolesAPIWorld(t)
world.resetCounters()
if _, err := world.update(c.caller, c.target, c.target, c.desc, 1); !errors.Is(err, c.want) {
t.Fatalf("%s: expected %v, got %v", c.name, c.want, err)
}
if world.roles.calls["Save"] != 0 || world.types.listCalls != 0 || world.roles.calls["List"] != 0 {
t.Errorf("%s: a refusal before the cover check must not list content types or roles, nor save", c.name)
}
}
world := newRolesAPIWorld(t)
world.resetCounters()
if _, err := world.update(raRoleManager, "Content lead", "Content lead", "", 1); !errors.Is(err, role.ErrEscalation) {
t.Fatalf("expected an escalation, got %v", err)
}
if world.roles.calls["Save"] != 0 {
t.Error("an escalation must not save")
}
if _, err := world.update(raRoleManager, "Contributor", "Contributor", "", 7, contributor()...); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("expected a conflict, got %v", err)
}
if world.roles.calls["Save"] != 0 {
t.Error("a stale version must not save")
}
})
t.Run("an unchanged edit writes nothing and records nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
unchanged, err := world.update(raAdmin, "Contributor", "Contributor", "", 1, contributor()...)
if err != nil || unchanged.Version != 1 || world.roles.calls["Save"] != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %+v %v saves=%d events=%d", unchanged, err, world.roles.calls["Save"], len(world.recorder.events))
}
})
t.Run("the editor and time are stored", func(t *testing.T) {
world := newRolesAPIWorld(t)
updated, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...)
if err != nil || updated.UpdatedBy == nil || updated.UpdatedBy.ID != raAdmin || updated.UpdatedAt == nil || !updated.UpdatedAt.Equal(world.now) {
t.Fatalf("unexpected editor %+v at %v: %v", updated.UpdatedBy, updated.UpdatedAt, err)
}
})
t.Run("a save that loses the race gives a conflict", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.beforeSave = func() {
stored := world.role("Contributor")
stored.Version = 2
world.roles.roles[stored.ID] = stored
}
if _, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("expected ErrRoleConflict, got %v", err)
}
if len(world.recorder.events) != 0 {
t.Error("a conflict is a refusal and records nothing")
}
})
t.Run("the edit event holds the diff, the text changes and the members", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.create(raAdmin, "Reviewer", "content.read=all", "content.update=own", "media.view=all")
if err != nil {
t.Fatal(err)
}
world.recorder.events = nil
edited, err := world.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{
Caller: world.caller(raAdmin), ID: created.ID, Name: "Reviewers", Description: "Reads", Version: 1,
Grants: grantRows("content.read=all", "content.update=all", "content.delete=own"),
})
if err != nil || edited.Version != 2 {
t.Fatalf("unexpected edit %+v %v", edited, err)
}
event := world.recorder.events[0]
if event.Action != "role.edit" || event.Category != audit.CategoryRoles || event.Target.Name != "Reviewers" {
t.Fatalf("unexpected event %+v", event)
}
if !reflect.DeepEqual(event.Params[audit.ParamAdded], grantRows("content.delete=own")) ||
!reflect.DeepEqual(event.Params[audit.ParamRemoved], grantRows("media.view=all")) ||
!reflect.DeepEqual(event.Params[audit.ParamChanged], []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) ||
event.Params[audit.ParamMembers] != 0 {
t.Fatalf("unexpected params %v", event.Params)
}
fields := map[string][2]string{}
for _, change := range event.Changes {
fields[change.Field] = [2]string{*change.Before, *change.After}
}
if fields[audit.FieldName] != [2]string{"Reviewer", "Reviewers"} || fields[audit.FieldDescription] != [2]string{"", "Reads"} {
t.Fatalf("unexpected changes %v", fields)
}
})
t.Run("a storage failure at the write records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Save")
if _, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...); err == nil {
t.Fatal("expected an error")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].Action != "role.edit" {
t.Fatalf("unexpected events %+v", world.recorder.events)
}
})
t.Run("a legacy admin is not mistaken for the role they edit", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raLegacyAdmin, "Admin", "Admin", "d", 1, adminRows()...)
requireMissingKey(t, err, role.KeyRolesEditOwnerOnly)
})
}
func writerRows() []string {
rows := []string{}
for _, grant := range role.BuiltInRoles()[2].Grants {
rows = append(rows, grant.Key+"="+string(grant.Scope))
}
return rows
}
func adminRows() []string {
rows := []string{}
for _, grant := range role.BuiltInRoles()[1].Grants {
rows = append(rows, grant.Key+"="+string(grant.Scope))
}
return rows
}
func withoutRow(rows []string, removed string) []string {
kept := []string{}
for _, row := range rows {
if row != removed {
kept = append(kept, row)
}
}
return kept
}
func TestResetRoleUseCase(t *testing.T) {
reset := func(world *rolesAPIWorld, caller uint32, roleID uint32, version uint32) (useCases.RoleView, error) {
return world.container.UpdateRoleUseCase.Reset(context.Background(), useCases.ResetRoleCommand{Caller: world.caller(caller), ID: roleID, Version: version})
}
edit := func(world *rolesAPIWorld, name string) {
if _, err := world.update(raOwner, name, name, "custom", 1, "content.read=all"); err != nil {
panic(err)
}
world.recorder.events = nil
}
t.Run("the owner restores the seeded grants and description, bumping the version once", func(t *testing.T) {
for _, name := range []string{"Writer", "Admin"} {
world := newRolesAPIWorld(t)
edit(world, name)
restored, err := reset(world, raOwner, world.ids[name], 2)
if err != nil || restored.Version != 3 || restored.Description != "" || restored.Name != name || !restored.BuiltIn {
t.Fatalf("%s: unexpected role %+v %v", name, restored, err)
}
seeded := role.Role{Grants: world.roles.byName(name).Grants}
want := role.BuiltInRoles()[map[string]int{"Admin": 1, "Writer": 2}[name]].Grants
if !reflect.DeepEqual(seeded.EffectiveGrants(), role.Role{Grants: want}.EffectiveGrants()) {
t.Fatalf("%s: grants are not the seeded ones", name)
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.edit" || world.recorder.events[0].Target.Name != name {
t.Fatalf("expected one role.edit event, got %+v", world.recorder.events)
}
}
})
t.Run("a pristine role is a no-op", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
same, err := reset(world, raOwner, world.ids["Writer"], 1)
if err != nil || same.Version != 1 || world.roles.calls["Save"] != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %+v %v", same, err)
}
})
t.Run("refusals", func(t *testing.T) {
world := newRolesAPIWorld(t)
edit(world, "Writer")
cases := []struct {
name string
caller uint32
id uint32
version uint32
want error
}{
{"stale version", raOwner, world.ids["Writer"], 1, role.ErrRoleConflict},
{"owner role", raOwner, world.ids["Owner"], 1, role.ErrRoleLocked},
{"custom role", raOwner, world.ids["Contributor"], 1, role.ErrNotBuiltIn},
{"missing role", raOwner, raUnknownRoleID, 1, role.ErrRoleNotFound},
{"admin caller", raAdmin, world.ids["Writer"], 2, role.ErrMissingPermission},
}
for _, c := range cases {
if _, err := reset(world, c.caller, c.id, c.version); !errors.Is(err, c.want) {
t.Errorf("%s: expected %v, got %v", c.name, c.want, err)
}
}
if len(world.recorder.events) != 0 {
t.Error("a refused reset records nothing")
}
})
}
func TestDeleteRoleUseCase(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
want error
key string
}{
{"owner deletes intern", raOwner, "Intern", nil, ""},
{"admin deletes intern", raAdmin, "Intern", nil, ""},
{"admin deletes contributor", raAdmin, "Contributor", role.ErrRoleHasMembers, ""},
{"owner deletes writer", raOwner, "Writer", role.ErrBuiltInRole, ""},
{"owner deletes admin", raOwner, "Admin", role.ErrBuiltInRole, ""},
{"owner deletes owner", raOwner, "Owner", role.ErrRoleLocked, ""},
{"admin deletes owner", raAdmin, "Owner", role.ErrRoleLocked, ""},
{"admin deletes a missing role", raAdmin, "Missing", role.ErrRoleNotFound, ""},
{"role manager deletes their own role", raRoleManager, "Role manager", role.ErrOwnRole, ""},
{"role manager deletes content lead", raRoleManager, "Content lead", role.ErrEscalation, "content.update"},
{"role manager deletes intern", raRoleManager, "Intern", nil, ""},
}
for _, c := range rows {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
err := world.remove(c.caller, c.target)
if c.want == nil {
if err != nil {
t.Fatalf("expected success, got %v", err)
}
if _, gone := world.roles.roles[world.ids[c.target]]; gone {
t.Fatal("the role must be gone")
}
event := world.recorder.events[0]
if event.Action != "role.delete" || event.Params[audit.ParamMembers] != 0 || event.Target.Name != c.target {
t.Fatalf("unexpected event %+v", event)
}
return
}
if !errors.Is(err, c.want) {
t.Fatalf("expected %v, got %v", c.want, err)
}
if c.key != "" {
requireEscalationKey(t, err, c.key)
}
if len(world.recorder.events) != 0 {
t.Fatal("a refusal records nothing")
}
if c.want != role.ErrRoleNotFound {
if _, kept := world.roles.roles[world.ids[c.target]]; !kept {
t.Fatal("a refused delete deletes nothing")
}
}
})
}
t.Run("a member appearing meanwhile still gives role_has_members and no event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNextWith("Delete", role.ErrRoleHasMembers)
if err := world.remove(raAdmin, "Intern"); !errors.Is(err, role.ErrRoleHasMembers) || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %v events=%d", err, len(world.recorder.events))
}
})
t.Run("a storage failure records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Delete")
if err := world.remove(raAdmin, "Intern"); err == nil {
t.Fatal("expected an error")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed {
t.Fatalf("unexpected events %+v", world.recorder.events)
}
})
}
func TestReassignRoleMembersUseCase(t *testing.T) {
t.Run("moving the contributors to Writer", func(t *testing.T) {
world := newRolesAPIWorld(t)
before := world.users.users[raContributorOne]
moved, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"])
if err != nil || moved != 4 {
t.Fatalf("expected 4 moved, got %d %v", moved, err)
}
after := world.users.users[raContributorOne]
if after.RoleID != world.ids["Writer"] {
t.Fatal("the member was not moved")
}
before.RoleID = after.RoleID
if !reflect.DeepEqual(before, after) {
t.Fatal("only role_id may change")
}
event := world.recorder.events[0]
if event.Action != "role.reassign" || event.Params[audit.ParamFrom] != world.ids["Contributor"] || event.Params[audit.ParamTo] != world.ids["Writer"] || event.Params[audit.ParamMoved] != 4 {
t.Fatalf("unexpected event %+v", event)
}
if err := world.remove(raAdmin, "Contributor"); err != nil {
t.Fatalf("the emptied role must be deletable, got %v", err)
}
})
t.Run("moving nobody records nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
moved, err := world.reassign(raAdmin, "Intern", world.ids["Writer"])
if err != nil || moved != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %d %v events=%d", moved, err, len(world.recorder.events))
}
})
refusals := []struct {
name string
caller uint32
source string
target func(*rolesAPIWorld) uint32
want error
}{
{"above the caller's grants", raRoleManager, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Admin"] }, role.ErrEscalation},
{"owner as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Owner"] }, role.ErrInvalidReassign},
{"same role as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Contributor"] }, role.ErrInvalidReassign},
{"unknown target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return raUnknownRoleID }, role.ErrInvalidReassign},
{"zero target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return 0 }, role.ErrInvalidReassign},
{"built-in source", raAdmin, "Writer", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrBuiltInRole},
{"owner source", raAdmin, "Owner", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrRoleLocked},
{"unknown source", raAdmin, "Missing", func(w *rolesAPIWorld) uint32 { return w.ids["Writer"] }, role.ErrRoleNotFound},
{"own role as source", raRoleManager, "Role manager", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrOwnRole},
{"source above the caller", raRoleManager, "Content lead", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrEscalation},
}
for _, c := range refusals {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
moved, err := world.reassign(c.caller, c.source, c.target(world))
if !errors.Is(err, c.want) || moved != 0 {
t.Fatalf("expected %v, got %d %v", c.want, moved, err)
}
if len(world.recorder.events) != 0 || len(world.members("Contributor")) != 4 {
t.Fatal("a refusal moves nobody and records nothing")
}
})
}
t.Run("a role manager may move members to a role they cover", func(t *testing.T) {
world := newRolesAPIWorld(t)
moved, err := world.reassign(raRoleManager, "Contributor", world.ids["Intern"])
if err != nil || moved != 4 {
t.Fatalf("expected 4 moved, got %d %v", moved, err)
}
})
t.Run("a repository failure moves nobody and records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("ReassignMembers")
if _, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"]); err == nil {
t.Fatal("expected an error")
}
if len(world.members("Contributor")) != 4 {
t.Fatal("nobody may be moved")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].Action != "role.reassign" {
t.Fatalf("unexpected events %+v", world.recorder.events)
}
})
}
func TestContentTypeTypedGrantCleanup(t *testing.T) {
t.Run("deleting a content type removes its typed rows and bumps only the affected roles", func(t *testing.T) {
world := newRolesAPIWorld(t)
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err != nil {
t.Fatal(err)
}
editor := world.role("Events editor")
if !reflect.DeepEqual(editor.Grants, grantRows("content.read=all", "content.update:venues=all")) || editor.Version != 2 {
t.Fatalf("unexpected editor role %v version %d", editor.Grants, editor.Version)
}
if world.role("Contributor").Version != 1 {
t.Fatal("a role without typed rows keeps its version")
}
})
t.Run("a refused delete keeps the typed rows", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.entries.entries[1] = entryOfType(raEventsTypeID)
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err == nil {
t.Fatal("expected a refusal")
}
if world.role("Events editor").Version != 1 || world.roles.calls["RemoveTypedGrants"] != 0 {
t.Fatal("nothing may be removed")
}
})
t.Run("a failure while removing the rows is reported", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("RemoveTypedGrants")
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err == nil {
t.Fatal("expected an error")
}
})
t.Run("creating a content type clears the stale typed rows of its key", func(t *testing.T) {
world := newRolesAPIWorld(t)
delete(world.types.types, raEventsTypeID)
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.update:events=none")})
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err != nil {
t.Fatal(err)
}
if got := world.roles.roles[stale.ID]; len(got.Grants) != 0 || got.Version != 2 {
t.Fatalf("unexpected role %+v", got)
}
})
t.Run("a refused create keeps the rows", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err == nil {
t.Fatal("expected the key to be taken")
}
if world.roles.calls["RemoveTypedGrants"] != 0 {
t.Fatal("nothing may be removed")
}
})
}
func TestGetMyAccessTypedGrants(t *testing.T) {
t.Run("the events editor lists typed entries including none, in order", func(t *testing.T) {
world := newRolesAPIWorld(t)
access, err := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[raEventsEditor])
if err != nil {
t.Fatal(err)
}
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
if !reflect.DeepEqual(access.Permissions, want) {
t.Fatalf("expected %v, got %v", want, access.Permissions)
}
})
t.Run("an unlocked role never lists an owner-only key", func(t *testing.T) {
world := newRolesAPIWorld(t)
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")})
world.addUser(60, user.RoleAdmin, hand.ID, true)
access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60])
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
t.Fatalf("unexpected permissions %v", access.Permissions)
}
})
}
+206
View File
@@ -0,0 +1,206 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
const (
raOwner uint32 = 3
raAdmin uint32 = 5
raWriter uint32 = 9
raLegacyAdmin uint32 = 10
raUnverified uint32 = 12
raRoleManager uint32 = 31
raViewer uint32 = 32
raTypedManager uint32 = 33
raNarrowManager uint32 = 34
raEventsEditor uint32 = 35
raContributorOne uint32 = 41
raContentLead uint32 = 45
raEventsTypeID uint32 = 1
raVenuesTypeID uint32 = 2
raUnknownRoleID uint32 = 999
)
type rolesAPIRoleSpec struct {
name string
grants []string
members []uint32
}
var rolesAPICustomRoles = []rolesAPIRoleSpec{
{"Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}, []uint32{41, 42, 43, 44}},
{"Intern", []string{"content.read=own"}, nil},
{"Content lead", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=all"}, []uint32{raContentLead}},
{"Role manager", []string{"roles.view=all", "roles.manage=all", "content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all"}, []uint32{raRoleManager}},
{"Viewer", []string{"roles.view=all"}, []uint32{raViewer}},
{"Typed manager", []string{"roles.view=all", "roles.manage=all", "content.read=all", "content.update=all", "content.update:article=none"}, []uint32{raTypedManager}},
{"Narrow manager", []string{"roles.view=all", "roles.manage=all", "content.read:events=all"}, []uint32{raNarrowManager}},
{"Events editor", []string{"content.read=all", "content.update:events=own", "content.delete:events=none", "content.update:venues=all"}, []uint32{raEventsEditor}},
{"Typed target", []string{"content.read=all", "content.update=own", "content.update:article=none"}, nil},
}
type rolesAPIWorld struct {
*apiKeyEnv
recorder *contentScopeRecorder
types *mockContentTypeRepo
entries *mockEntryRepo
ids map[string]uint32
}
func newRolesAPIWorld(t *testing.T) *rolesAPIWorld {
t.Helper()
recorder := &contentScopeRecorder{}
env := newAPIKeyEnvRecording(t, recorder)
world := &rolesAPIWorld{apiKeyEnv: env, recorder: recorder, types: newMockContentTypeRepo(), entries: newMockEntryRepo(), ids: map[string]uint32{}}
env.users.users = map[uint32]user.User{}
env.roles.users = env.users
world.seedBuiltIns()
world.seedRoles()
world.seedContentTypes()
world.wire()
return world
}
func (w *rolesAPIWorld) seedBuiltIns() {
w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID
w.addUser(raOwner, user.RoleOwner, ownerRoleID, true)
w.addUser(raAdmin, user.RoleAdmin, adminRoleID, true)
w.addUser(raLegacyAdmin, user.RoleAdmin, 0, true)
w.addUser(raWriter, "", writerRoleID, true)
w.addUser(raUnverified, user.RoleAdmin, adminRoleID, false)
}
func (w *rolesAPIWorld) seedRoles() {
for _, spec := range rolesAPICustomRoles {
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
w.ids[spec.name] = created.ID
for _, member := range spec.members {
w.addUser(member, "", created.ID, true)
}
}
}
func (w *rolesAPIWorld) seedContentTypes() {
title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}
w.types.types[raEventsTypeID] = contenttype.ContentType{ID: raEventsTypeID, Key: "events", Name: "Events", Fields: []contenttype.Field{title}}
w.types.types[raVenuesTypeID] = contenttype.ContentType{ID: raVenuesTypeID, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}}
w.types.nextID = 10
}
func validContentTypeFields() []contenttype.Field {
return []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}}
}
func entryOfType(typeID uint32) entry.Entry {
return entry.Entry{ID: 1, ContentTypeID: typeID}
}
func (w *rolesAPIWorld) clock() time.Time {
return w.now
}
func (w *rolesAPIWorld) wire() {
c := w.container
c.ListRolesUseCase = useCases.NewListRolesUseCase(w.roles, w.users, w.types)
c.GetRoleUseCase = useCases.NewGetRoleUseCase(w.roles, w.users, w.types)
c.CreateRoleUseCase = useCases.NewCreateRoleUseCase(w.roles, w.users, w.types, w.recorder)
c.UpdateRoleUseCase = useCases.NewUpdateRoleUseCase(w.roles, w.users, w.types, w.recorder, w.clock)
c.DeleteRoleUseCase = useCases.NewDeleteRoleUseCase(w.roles, w.users, w.types, w.recorder)
c.ReassignRoleMembersUseCase = useCases.NewReassignRoleMembersUseCase(w.roles, w.users, w.types, w.recorder)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.types, w.roles, w.clock)
c.ListContentTypesUseCase = useCases.NewListContentTypesUseCase(w.types)
c.GetContentTypeUseCase = useCases.NewGetContentTypeUseCase(w.types)
c.UpdateContentTypeUseCase = useCases.NewUpdateContentTypeUseCase(w.types, w.entries, w.clock)
c.DeleteContentTypeUseCase = useCases.NewDeleteContentTypeUseCase(w.types, w.entries, w.roles)
w.rebuildRouter()
}
func (w *rolesAPIWorld) role(name string) role.Role {
found, err := w.roles.Get(w.ids[name])
if err != nil {
panic("unknown role " + name)
}
return found
}
func (w *rolesAPIWorld) path(name string) string {
return "/v1/role/" + strconv.FormatUint(uint64(w.ids[name]), 10)
}
func (w *rolesAPIWorld) caller(userID uint32) useCases.ContentCaller {
account := w.users.users[userID]
roleID := account.RoleID
if roleID == 0 {
roleID = w.ids[map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"}[account.Role]]
}
return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[roleID].GrantSet()}
}
func (w *rolesAPIWorld) members(name string) []uint32 {
found := []uint32{}
for _, id := range w.users.sortedIds() {
if w.users.users[id].RoleID == w.ids[name] {
found = append(found, id)
}
}
return found
}
func (w *rolesAPIWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller})
}
func (w *rolesAPIWorld) resetCounters() {
w.roles.calls = map[string]int{}
w.types.listCalls = 0
}
func rolesAPIUpdateBody(name string, description string, version uint32, grants ...string) string {
return `{"name": ` + strconv.Quote(name) + `, "description": ` + strconv.Quote(description) + `, "version": ` + strconv.FormatUint(uint64(version), 10) + `, "grants": ` + rolesAPIGrantsJSON(grants...) + `}`
}
func rolesAPIGrantsJSON(rows ...string) string {
body := "["
for index, grant := range grantRows(rows...) {
if index > 0 {
body += ", "
}
body += `{"key": ` + strconv.Quote(grant.Key) + `, "scope": ` + strconv.Quote(string(grant.Scope)) + `}`
}
return body + "]"
}
func rolesAPIContributorRows(extra ...string) []string {
return append([]string{"content.read=all", "content.create=all", "content.update=own"}, extra...)
}
func assertRefusal(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if rec.Code != status || rec.Body.String() != code+"\n" {
t.Fatalf("expected %d %q, got %d %q", status, code, rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Missing-Permission") != "" || rec.Header().Get("X-Escalation-Key") != "" {
t.Fatalf("unexpected permission header on %q", code)
}
}
func assertEscalation(t *testing.T, rec *httptest.ResponseRecorder, key string) {
t.Helper()
if rec.Code != http.StatusForbidden || rec.Body.String() != "escalation\n" {
t.Fatalf("expected 403 escalation, got %d %q", rec.Code, rec.Body.String())
}
if header := rec.Header().Get("X-Escalation-Key"); key != "" && header != key {
t.Fatalf("expected X-Escalation-Key %q, got %q", key, header)
}
}
+11 -11
View File
@@ -583,8 +583,8 @@ func TestContentScopeRequestLoads(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 1 user Get, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
t.Errorf("expected at most 1 user Get, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
}
})
t.Run("An API key request loads the user at most twice: "+route[0]+" "+route[1], func(t *testing.T) {
@@ -595,30 +595,30 @@ func TestContentScopeRequestLoads(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
}
})
}
t.Run("A legacy owner is resolved by one GetByName and no ListGrants", func(t *testing.T) {
t.Run("A legacy owner is resolved by one GetByName and no List call", func(t *testing.T) {
world := newContentScopeWorld(t)
legacy := world.users.users[csOwner]
legacy.RoleID = 0
world.users.users[csOwner] = legacy
world.resetCounters()
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.listGrantsN != 0 {
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 {
t.Errorf("unexpected role loads %+v", world.roleRepo)
}
})
t.Run("GET /auth/me does not call ListGrants", func(t *testing.T) {
t.Run("GET /auth/me does not call List", func(t *testing.T) {
world := newContentScopeWorld(t)
world.resetCounters()
rec, _ := world.me(t, csWriter)
if rec.Code != http.StatusOK || world.roleRepo.listGrantsN != 0 {
t.Errorf("unexpected %d and %d ListGrants calls", rec.Code, world.roleRepo.listGrantsN)
if rec.Code != http.StatusOK || world.roleRepo.lists != 0 {
t.Errorf("unexpected %d and %d List calls", rec.Code, world.roleRepo.lists)
}
})
}
@@ -654,8 +654,8 @@ func TestContentScopeOwnerOnlyAndMe(t *testing.T) {
for _, grant := range me.Access.Permissions {
got[grant.Key] = string(grant.Scope)
}
if got["content.update"] != "own" || len(got) != 1 {
t.Errorf("expected only content.update at own, got %v", got)
if got["content.update"] != "own" || got["content.update:blog"] != "all" || len(got) != 2 {
t.Errorf("expected content.update at own and the typed entry (superseded by roles_permissions_roles_api), got %v", got)
}
})
}
+7 -6
View File
@@ -9,6 +9,7 @@ import (
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"reflect"
"testing"
"time"
)
@@ -86,14 +87,14 @@ func TestContentScopeCheckPermissionUseCase(t *testing.T) {
if _, err := check.Require(writer, "content.read"); err != nil {
t.Fatal(err)
}
if counting.gets != 1 || counting.getByNames != 0 || counting.listGrantsN != 0 {
if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 {
t.Errorf("unexpected loads %+v", counting)
}
counting.gets = 0
if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil {
t.Fatal(err)
}
if counting.gets != 0 || counting.getByNames != 1 || counting.listGrantsN != 0 {
if counting.gets != 0 || counting.getByNames != 1 || counting.lists != 0 {
t.Errorf("unexpected legacy loads %+v", counting)
}
})
@@ -120,10 +121,10 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(access.Permissions) != 1 || access.Permissions[0].Key != "content.update" || access.Permissions[0].Scope != role.ScopeOwn {
t.Errorf("unexpected permissions %+v", access.Permissions)
if !reflect.DeepEqual(access.Permissions, grantRows("content.update=own", "content.update:blog=all")) {
t.Errorf("unexpected permissions %+v (typed entries superseded by roles_permissions_roles_api)", access.Permissions)
}
if counting.gets != 1 || counting.listGrantsN != 0 {
if counting.gets != 1 || counting.lists != 0 {
t.Errorf("unexpected loads %+v", counting)
}
})
@@ -478,7 +479,7 @@ func TestContentScopeCreateContentTypeReservesBuiltInKeys(t *testing.T) {
for _, key := range []string{"article", "author", "site", "media", "user"} {
t.Run(key, func(t *testing.T) {
types := newMockContentTypeRepo()
create := useCases.NewCreateContentTypeUseCase(types, time.Now)
create := useCases.NewCreateContentTypeUseCase(types, newMockRoleRepo(), time.Now)
_, err := create.Create(useCases.CreateContentTypeCommand{Key: key, Name: "Reserved", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}})
if !errors.Is(err, contenttype.ErrKeyTaken) || types.createCalls != 0 {
t.Errorf("expected ErrKeyTaken and nothing stored, got %v (%d)", err, types.createCalls)
+9 -9
View File
@@ -61,9 +61,9 @@ func (r *contentScopeRecorder) actions() []string {
type contentScopeRoleRepo struct {
role.Repository
gets int
getByNames int
listGrantsN int
gets int
getByNames int
lists int
}
func (c *contentScopeRoleRepo) Get(id uint32) (role.Role, error) {
@@ -76,13 +76,13 @@ func (c *contentScopeRoleRepo) GetByName(name string) (role.Role, error) {
return c.Repository.GetByName(name)
}
func (c *contentScopeRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
c.listGrantsN++
return c.Repository.ListGrants(roleID)
func (c *contentScopeRoleRepo) List() ([]role.Role, error) {
c.lists++
return c.Repository.List()
}
func (c *contentScopeRoleRepo) touched() int {
return c.gets + c.getByNames + c.listGrantsN
return c.gets + c.getByNames + c.lists
}
type contentScopeUserRepo struct {
@@ -219,7 +219,7 @@ func (w *contentScopeWorld) wire() {
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, clock)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images)
@@ -234,7 +234,7 @@ func (w *contentScopeWorld) do(t *testing.T, caller uint32, method string, path
}
func (w *contentScopeWorld) resetCounters() {
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.listGrantsN = 0, 0, 0
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.lists = 0, 0, 0
w.userRepo.gets = 0
w.typeRepo.lookups = 0
}