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) } }) }