887 lines
38 KiB
Go
887 lines
38 KiB
Go
package test
|
|
|
|
import (
|
|
"RenewCMS/internal/domain/audit"
|
|
"RenewCMS/internal/domain/role"
|
|
"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, world.users).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, hand.ID, true)
|
|
access, _ := useCases.NewGetMyAccessUseCase(world.roles, world.users).Get(world.users.users[60])
|
|
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
|
|
t.Fatalf("unexpected permissions %v", access.Permissions)
|
|
}
|
|
})
|
|
}
|