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:
1 parent
a043f7f41a
commit
094df63a7f
51 files changed
+4822
-115
No files matched your search
@@ -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"}},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user