package test import ( "RenewCMS/internal/domain/audit" "RenewCMS/internal/domain/invite" "RenewCMS/internal/domain/role" "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "errors" "reflect" "strconv" "testing" "time" ) func usersAPIEscalationKey(t *testing.T, err error) string { t.Helper() var escalation role.EscalationError if !errors.As(err, &escalation) { t.Fatalf("expected an escalation error, got %v", err) } return escalation.Key } func (w *usersAPIWorld) roleIDFor(name string) uint32 { switch name { case "": return 0 case "unknown": return uaUnknownRole } return w.ids[name] } type usersAPIOutcome struct { err error escalation string } func (o usersAPIOutcome) check(t *testing.T, err error) { t.Helper() switch { case o.escalation != "": if !errors.Is(err, role.ErrEscalation) || usersAPIEscalationKey(t, err) != o.escalation { t.Fatalf("expected escalation on %q, got %v", o.escalation, err) } case o.err != nil: if !errors.Is(err, o.err) { t.Fatalf("expected %v, got %v", o.err, err) } default: if err != nil { t.Fatalf("expected success, got %v", err) } } } func (o usersAPIOutcome) refused() bool { return o.err != nil || o.escalation != "" } func TestUsersAPIChangeUserRoleUseCase(t *testing.T) { ok := usersAPIOutcome{} rows := []struct { name string caller uint32 target uint32 role string outcome usersAPIOutcome }{ {"Admin moves a Contributor to Writer", uaAdmin, 41, "Writer", ok}, {"Admin covers Admin", uaAdmin, 41, "Admin", ok}, {"Admin demotes another Admin", uaAdmin, uaOtherAdmin, "Contributor", ok}, {"Owner moves an Admin to Content lead", uaOwner, uaAdmin, "Content lead", ok}, {"People manager moves a Contributor to Intern", uaPeople, 41, "Intern", ok}, {"People manager cannot give Writer", uaPeople, 41, "Writer", usersAPIOutcome{escalation: "content.update"}}, {"People manager cannot give Content lead", uaPeople, 41, "Content lead", usersAPIOutcome{escalation: "content.update"}}, {"People manager cannot touch a Content lead", uaPeople, uaContentLead, "Intern", usersAPIOutcome{escalation: "content.update"}}, {"People manager cannot touch an Admin", uaPeople, uaAdmin, "Intern", usersAPIOutcome{escalation: "content.update"}}, {"Admin cannot give Owner", uaAdmin, 41, "Owner", usersAPIOutcome{err: role.ErrRoleLocked}}, {"Owner cannot give Owner", uaOwner, 41, "Owner", usersAPIOutcome{err: role.ErrRoleLocked}}, {"Admin cannot change the owner", uaAdmin, uaOwner, "Admin", usersAPIOutcome{err: role.ErrRoleLocked}}, {"Owner role is checked before the own account", uaOwner, uaOwner, "Admin", usersAPIOutcome{err: role.ErrRoleLocked}}, {"Admin cannot change its own role", uaAdmin, uaAdmin, "Writer", usersAPIOutcome{err: role.ErrOwnRole}}, {"People manager cannot change its own role", uaPeople, uaPeople, "Intern", usersAPIOutcome{err: role.ErrOwnRole}}, {"unknown user", uaAdmin, 999, "Writer", usersAPIOutcome{err: user.ErrUserNotFound}}, {"unknown role", uaAdmin, 41, "unknown", usersAPIOutcome{err: role.ErrUnknownRole}}, {"role id zero", uaAdmin, 41, "", usersAPIOutcome{err: role.ErrUnknownRole}}, {"unverified user may be given a role", uaAdmin, uaUnverified, "Writer", ok}, {"unknown role before escalation on a higher role", uaPeople, 41, "unknown", usersAPIOutcome{err: role.ErrUnknownRole}}, {"locked current role before an unknown role", uaAdmin, uaOwner, "unknown", usersAPIOutcome{err: role.ErrRoleLocked}}, } for _, row := range rows { t.Run(row.name, func(t *testing.T) { w := newUsersAPIWorld(t) before := w.storedRoleID(row.target) got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(row.caller), TargetID: row.target, RoleID: w.roleIDFor(row.role)}) row.outcome.check(t, err) if row.outcome.refused() { if w.users.updateRoleCalls != 0 { t.Errorf("a refused call must not reach the repository write") } if len(w.recorder.events) != 0 { t.Errorf("a refused call must record nothing, got %v", w.recorder.actions()) } if after := w.storedRoleID(row.target); after != before { t.Errorf("role changed from %d to %d on a refusal", before, after) } return } if got.Role == nil || got.Role.ID != w.ids[row.role] || got.ID != row.target || got.Kind != useCases.EntryKindUser { t.Errorf("unexpected result %+v", got) } if w.storedRoleID(row.target) != w.ids[row.role] { t.Errorf("stored role is %d", w.storedRoleID(row.target)) } if before != w.ids[row.role] && len(w.recorder.events) != 1 { t.Errorf("expected one event, got %v", w.recorder.actions()) } }) } t.Run("giving the same role again writes nothing and records nothing", func(t *testing.T) { w := newUsersAPIWorld(t) got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Contributor")}) if err != nil || got.Role == nil || got.Role.Name != "Contributor" { t.Fatalf("unexpected %+v, %v", got, err) } if w.users.updateRoleCalls != 0 || len(w.recorder.events) != 0 { t.Errorf("a no-op must not write or record") } }) t.Run("a same-role call by a caller who does not cover it is still an escalation", func(t *testing.T) { w := newUsersAPIWorld(t) changeOnly := w.createTemp("users.change_role=all") w.addMember(70, "narrow", "narrow@renew.example", changeOnly, true) _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(70), TargetID: 41, RoleID: w.id("Contributor")}) if !errors.Is(err, role.ErrEscalation) { t.Fatalf("expected escalation, got %v", err) } }) t.Run("a stale read loses the compare-and-swap", func(t *testing.T) { w := newUsersAPIWorld(t) w.users.beforeUpdateRole = func() { rival := w.users.users[41] rival.RoleID = w.id("Intern") w.users.users[41] = rival } _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")}) if !errors.Is(err, user.ErrRoleChanged) { t.Fatalf("expected ErrRoleChanged, got %v", err) } if w.storedRoleID(41) != w.id("Intern") || len(w.recorder.events) != 0 { t.Errorf("the winner must stay and nothing is recorded") } }) t.Run("a role deleted meanwhile is an invalid role", func(t *testing.T) { w := newUsersAPIWorld(t) temp := w.createTemp("content.read=own") w.users.beforeUpdateRole = func() { delete(w.roles.roles, temp) } _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: temp}) if !errors.Is(err, role.ErrUnknownRole) { t.Fatalf("expected ErrUnknownRole, got %v", err) } if w.storedRoleID(41) != w.id("Contributor") || len(w.recorder.events) != 0 { t.Errorf("the user must keep the Contributor role and nothing is recorded") } }) t.Run("the event names the roles and the user", func(t *testing.T) { w := newUsersAPIWorld(t) if _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")}); err != nil { t.Fatal(err) } event := w.recorder.events[0] if event.Action != audit.ActionUserChangeRole || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess || event.Security { t.Errorf("unexpected event %+v", event) } if event.Target != audit.NewTarget(audit.TargetUser, 41, "user41") { t.Errorf("unexpected target %+v", event.Target) } if len(event.Changes) != 1 || !reflect.DeepEqual(event.Changes[0], audit.TextChange(audit.FieldRole, "Contributor", "Writer")) { t.Errorf("unexpected changes %+v", event.Changes) } if event.Params[audit.ParamFrom] != w.id("Contributor") || event.Params[audit.ParamTo] != w.id("Writer") { t.Errorf("unexpected params %v", event.Params) } }) t.Run("a storage failure records a failed event", func(t *testing.T) { w := newUsersAPIWorld(t) w.users.fail.failNext("UpdateRole") _, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: 41, RoleID: w.id("Writer")}) if !errors.Is(err, errRepositoryFailure) { t.Fatalf("expected the repository failure, got %v", err) } if len(w.recorder.events) != 1 || w.recorder.events[0].Outcome != audit.OutcomeFailed || w.recorder.events[0].FailureReason == nil { t.Fatalf("expected one failed event, got %+v", w.recorder.events) } }) t.Run("a user whose role cannot be resolved can be repaired by a covering caller", func(t *testing.T) { w := newUsersAPIWorld(t) got, err := w.container.ChangeUserRoleUseCase.ChangeRole(testCtx, useCases.ChangeUserRoleCommand{Caller: w.caller(uaAdmin), TargetID: uaNoRole, RoleID: w.id("Writer")}) if err != nil || got.Role == nil || got.Role.Name != "Writer" { t.Fatalf("unexpected %+v, %v", got, err) } }) } func TestUsersAPITransferOwnershipUseCase(t *testing.T) { command := func(w *usersAPIWorld, target uint32, email string) useCases.TransferOwnershipCommand { return useCases.TransferOwnershipCommand{Caller: w.caller(uaOwner), UserID: target, ConfirmEmail: email} } t.Run("the owner role moves and the former owner becomes Admin", func(t *testing.T) { w := newUsersAPIWorld(t) got, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example")) if err != nil { t.Fatal(err) } if w.storedRoleID(uaAdmin) != ownerRoleID || w.storedRoleID(uaOwner) != adminRoleID { t.Errorf("roles after the transfer: %d and %d", w.storedRoleID(uaAdmin), w.storedRoleID(uaOwner)) } if got.Owner.ID != uaAdmin || got.Owner.Name != "jonas" || got.FormerOwner.ID != uaOwner || got.FormerOwner.Role.Name != "Admin" || got.FormerOwner.Role.ID != adminRoleID { t.Errorf("unexpected result %+v", got) } owners := 0 for _, member := range w.users.users { if member.RoleID == ownerRoleID && member.IsVerified { owners++ } } if owners != 1 { t.Errorf("expected exactly one owner, got %d", owners) } if w.users.users[uaAdmin].TokenVersion != 0 || w.users.users[uaOwner].TokenVersion != 0 { t.Errorf("token versions must not change") } event := w.recorder.events[0] if len(w.recorder.events) != 1 || event.Action != audit.ActionUserTransferOwnership || event.Category != audit.CategoryUsers || !event.Security || event.Outcome != audit.OutcomeSuccess { t.Fatalf("unexpected events %+v", w.recorder.events) } if event.Target != audit.NewTarget(audit.TargetUser, uaAdmin, "jonas") || event.Params[audit.ParamFrom] != uaOwner || event.Params[audit.ParamTo] != uaAdmin { t.Errorf("unexpected event %+v", event) } }) refusals := []struct { name string target uint32 email string want error }{ {"unknown user", 999, "x@renew.example", user.ErrInvalidTransfer}, {"yourself", uaOwner, "marta@renew.example", user.ErrInvalidTransfer}, {"an unverified user", uaUnverified, "sam@renew.example", user.ErrInvalidTransfer}, {"a mismatching email", uaAdmin, "JONAS@renew.example", user.ErrConfirmationMismatch}, {"an empty email", uaAdmin, "", user.ErrConfirmationMismatch}, } for _, row := range refusals { t.Run("refuses "+row.name, func(t *testing.T) { w := newUsersAPIWorld(t) _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, row.target, row.email)) if !errors.Is(err, row.want) { t.Fatalf("expected %v, got %v", row.want, err) } if w.users.transferCalls != 0 || len(w.recorder.events) != 0 || w.storedRoleID(uaOwner) != ownerRoleID { t.Errorf("a refusal must write and record nothing") } }) } t.Run("a caller who lost the owner role meanwhile loses the swap", func(t *testing.T) { w := newUsersAPIWorld(t) w.users.beforeTransfer = func() { _ = w.users.TransferOwnership(uaOwner, uaOtherAdmin, ownerRoleID, adminRoleID) } _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example")) if !errors.Is(err, user.ErrRoleChanged) { t.Fatalf("expected ErrRoleChanged, got %v", err) } if w.storedRoleID(uaOtherAdmin) != ownerRoleID || w.storedRoleID(uaAdmin) != adminRoleID || len(w.recorder.events) != 0 { t.Errorf("the first transfer must stand and nothing is recorded") } }) t.Run("a storage failure changes nobody and records a failed event", func(t *testing.T) { w := newUsersAPIWorld(t) w.users.fail.failNext("TransferOwnership") _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, command(w, uaAdmin, "jonas@renew.example")) if !errors.Is(err, errRepositoryFailure) { t.Fatalf("expected the repository failure, got %v", err) } if w.storedRoleID(uaOwner) != ownerRoleID || w.storedRoleID(uaAdmin) != adminRoleID { t.Errorf("nobody must change") } if len(w.recorder.events) != 1 || w.recorder.events[0].Outcome != audit.OutcomeFailed || !w.recorder.events[0].Security { t.Errorf("expected one failed security event, got %+v", w.recorder.events) } }) t.Run("a custom role named Admin is not the built-in Admin", func(t *testing.T) { w := newUsersAPIWorld(t) cmd := command(w, uaAdmin, "jonas@renew.example") delete(w.roles.roles, adminRoleID) _, _ = w.roles.Create(testRole("Admin")) _, err := w.container.TransferOwnershipUseCase.Transfer(testCtx, cmd) if !errors.Is(err, role.ErrRoleNotFound) || w.storedRoleID(uaOwner) != ownerRoleID || w.users.transferCalls != 0 { t.Fatalf("expected ErrRoleNotFound and no write, got %v", err) } }) } func testRole(name string) role.Role { return role.Role{Name: name, Version: 1, Grants: grantRows("content.read=own")} } func TestUsersAPICreateInviteUseCase(t *testing.T) { ok := usersAPIOutcome{} rows := []struct { name string caller uint32 email string role string outcome usersAPIOutcome }{ {"default Writer", uaAdmin, "a@renew.example", "", ok}, {"Contributor", uaAdmin, "a@renew.example", "Contributor", ok}, {"Admin", uaAdmin, "a@renew.example", "Admin", ok}, {"Owner gives a Content lead", uaOwner, "a@renew.example", "Content lead", ok}, {"Owner role is locked", uaAdmin, "a@renew.example", "Owner", usersAPIOutcome{err: role.ErrRoleLocked}}, {"unknown role", uaAdmin, "a@renew.example", "unknown", usersAPIOutcome{err: role.ErrUnknownRole}}, {"People manager invites an Intern", uaPeople, "a@renew.example", "Intern", ok}, {"People manager cannot invite the default Writer", uaPeople, "a@renew.example", "", usersAPIOutcome{escalation: "content.update"}}, {"People manager cannot invite a Content lead", uaPeople, "a@renew.example", "Content lead", usersAPIOutcome{escalation: "content.update"}}, {"Inviter cannot invite the default Writer", uaInviter, "a@renew.example", "", usersAPIOutcome{escalation: "content.read"}}, {"pending invite", uaAdmin, "new3@renew.example", "", usersAPIOutcome{err: invite.ErrPendingInvite}}, {"email with an account", uaAdmin, "ines@renew.example", "", usersAPIOutcome{err: invite.ErrEmailHasAccount}}, } for _, row := range rows { t.Run(row.name, func(t *testing.T) { w := newUsersAPIWorld(t) invitesBefore := len(w.invites.invites) got, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(row.caller), Email: row.email, InvitedBy: row.caller, RoleID: w.roleIDFor(row.role)}) row.outcome.check(t, err) if row.outcome.refused() { if len(w.invites.invites) != invitesBefore || len(w.mail.SentEmails) != 0 { t.Errorf("a refusal must create no invite and send no mail") } return } wantRole := row.role if wantRole == "" { wantRole = "Writer" } if got.RoleID != w.ids[wantRole] || w.invites.invites[got.ID].RoleID != w.ids[wantRole] { t.Errorf("invite role is %d, want %s", got.RoleID, wantRole) } if len(w.mail.SentEmails) != 1 || w.mail.SentEmails[0].Template != "invitation" { t.Errorf("expected one invitation mail, got %+v", w.mail.SentEmails) } params := w.recorder.events[len(w.recorder.events)-1].Params if params[audit.ParamRoleID] != w.ids[wantRole] || params[audit.ParamRole] != wantRole { t.Errorf("unexpected invite.create params %v", params) } }) } t.Run("a refused role leaves the expired invite in place", func(t *testing.T) { w := newUsersAPIWorld(t) _, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "old2@renew.example", InvitedBy: uaAdmin, RoleID: uaUnknownRole}) if !errors.Is(err, role.ErrUnknownRole) { t.Fatalf("expected ErrUnknownRole, got %v", err) } if _, present := w.invites.invites[2]; !present { t.Errorf("expired invite 2 must not be deleted") } }) t.Run("an expired invite is replaced by a new one for the requested role", func(t *testing.T) { w := newUsersAPIWorld(t) got, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "old2@renew.example", InvitedBy: uaAdmin, RoleID: writerRoleID}) if err != nil { t.Fatal(err) } if _, present := w.invites.invites[2]; present || got.RoleID != writerRoleID { t.Errorf("expired invite 2 must be replaced for Writer") } }) t.Run("a failed mail rolls the invite back", func(t *testing.T) { w := newUsersAPIWorld(t) w.mail.fail.failNext("Send") _, err := w.container.CreateInviteUseCase.CreateInvite(testCtx, useCases.CreateInviteCommand{Caller: w.caller(uaAdmin), Email: "a@renew.example", InvitedBy: uaAdmin}) if !errors.Is(err, invite.ErrInviteEmailNotSent) || len(w.invites.invites) != 4 { t.Fatalf("expected a rollback, got %v with %d invites", err, len(w.invites.invites)) } }) } func TestUsersAPIRenewAndCancelInviteUseCases(t *testing.T) { renew := func(w *usersAPIWorld, caller uint32, id uint32) (useCases.InviteView, error) { return w.container.RenewInviteUseCase.RenewInvite(testCtx, useCases.RenewInviteCommand{Caller: w.caller(caller), ID: id, RenewedBy: caller}) } t.Run("renewing keeps the role of an expired invite", func(t *testing.T) { w := newUsersAPIWorld(t) got, err := renew(w, uaAdmin, 2) if err != nil || got.RoleID != w.id("Contributor") || got.StatusAt(time.Now()) != invite.StatusPending { t.Fatalf("unexpected %+v, %v", got, err) } if len(w.mail.SentEmails) != 1 { t.Errorf("a new code must be mailed") } }) t.Run("People manager renews a Contributor invite", func(t *testing.T) { w := newUsersAPIWorld(t) if _, err := renew(w, uaPeople, 3); err != nil { t.Fatal(err) } }) t.Run("People manager cannot renew a Writer invite", func(t *testing.T) { w := newUsersAPIWorld(t) before := w.invites.invites[1] _, err := renew(w, uaPeople, 1) if usersAPIEscalationKey(t, err) != "content.update" { t.Fatalf("unexpected %v", err) } if w.invites.invites[1].HashedCode != before.HashedCode || len(w.mail.SentEmails) != 0 { t.Errorf("nothing may be renewed or mailed") } }) t.Run("not found comes before the coverage check", func(t *testing.T) { w := newUsersAPIWorld(t) if _, err := renew(w, uaPeople, 999); !errors.Is(err, invite.ErrInviteNotFound) { t.Fatalf("unexpected %v", err) } }) t.Run("an accepted invite is a conflict before the coverage check", func(t *testing.T) { w := newUsersAPIWorld(t) if _, err := renew(w, uaPeople, 4); !errors.Is(err, invite.ErrInviteAlreadyAccepted) { t.Fatalf("unexpected %v", err) } }) t.Run("cancelling needs no coverage", func(t *testing.T) { w := newUsersAPIWorld(t) if err := w.container.CancelInviteUseCase.CancelInvite(testCtx, 1); err != nil { t.Fatal(err) } if _, present := w.invites.invites[1]; present { t.Errorf("invite 1 must be gone") } }) } func TestUsersAPIAcceptInviteUseCase(t *testing.T) { for _, name := range []string{"Writer", "Contributor", "Admin"} { t.Run("the new user gets the role of the invite: "+name, func(t *testing.T) { w := newUsersAPIWorld(t) pending := w.invites.invites[3] pending.RoleID = w.ids[name] w.invites.invites[3] = pending created, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) if err != nil { t.Fatal(err) } stored := w.users.users[created.ID] if stored.RoleID != w.ids[name] || !stored.IsVerified || stored.Username != "kim" { t.Errorf("unexpected stored user %+v", stored) } params := w.recorder.events[len(w.recorder.events)-1].Params if params[audit.ParamRoleID] != w.ids[name] || params[audit.ParamRole] != name { t.Errorf("unexpected invite.accept params %v", params) } }) } for name, roleID := range map[string]uint32{"role id zero": 0, "a deleted role": 99} { t.Run("an invite with "+name+" is invalid", func(t *testing.T) { w := newUsersAPIWorld(t) pending := w.invites.invites[3] pending.RoleID = roleID w.invites.invites[3] = pending usersBefore := len(w.users.users) _, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) if !errors.Is(err, invite.ErrInviteInvalid) { t.Fatalf("expected ErrInviteInvalid, got %v", err) } if len(w.users.users) != usersBefore || w.invites.invites[3].AcceptedAt != nil { t.Errorf("no user may be created and the invite stays open") } last := w.recorder.events[len(w.recorder.events)-1] if last.Action != audit.ActionInviteAccept || last.Outcome != audit.OutcomeFailed { t.Errorf("expected a failed invite.accept event, got %+v", last) } }) } t.Run("grants edited before the accept are what the new user gets", func(t *testing.T) { w := newUsersAPIWorld(t) contributor := w.roles.roles[w.id("Contributor")] contributor.Grants = grantRows("content.read=all", "media.view=all") w.roles.roles[contributor.ID] = contributor created, err := w.container.AcceptInviteUseCase.AcceptInvite(testCtx, useCases.AcceptInviteCommand{Code: "code3", Username: "kim", Password: "password1"}) if err != nil { t.Fatal(err) } access, err := w.container.GetMyAccessUseCase.Get(w.users.users[created.ID]) if err != nil || access.Role.Name != "Contributor" || len(access.Permissions) != 2 { t.Fatalf("unexpected access %+v, %v", access, err) } }) } func TestUsersAPICreateUserUseCase(t *testing.T) { t.Run("registration stores the Owner role id", func(t *testing.T) { w := newUsersAPIWorld(t) w.users.users = map[uint32]user.User{} created, err := w.container.CreateUserUseCase.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}) if err != nil { t.Fatal(err) } stored := w.users.users[created.ID] if stored.RoleID != ownerRoleID || stored.IsVerified { t.Errorf("unexpected stored user %+v", stored) } }) t.Run("an unseeded Owner role fails and creates no user", func(t *testing.T) { users := newMockUserRepo() uc := useCases.NewCreateUserUseCase(users, newMockRoleRepo(), &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}) if !errors.Is(err, role.ErrRoleNotFound) || len(users.users) != 0 { t.Fatalf("expected ErrRoleNotFound with no user, got %v and %d users", err, len(users.users)) } }) t.Run("a failed verification mail and a failed cleanup report both errors", func(t *testing.T) { users := newMockUserRepo() mailer := newMockMailRepo() cleanup := errors.New("cleanup failed") mailer.fail.failNextWith("Send", errors.New("smtp down")) users.fail.failNextWith("Delete", cleanup) uc := useCases.NewCreateUserUseCase(users, newSeededMockRoleRepo(), &stubHasher{}, mailer, "https://cms.example.com", discardRecorder) _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}) if !errors.Is(err, user.ErrVerificationEmailNotSent) || !errors.Is(err, cleanup) { t.Fatalf("expected both failures, got %v", err) } }) t.Run("a custom role named Owner is not the built-in Owner", func(t *testing.T) { roles := newMockRoleRepo() _, _ = roles.Create(role.Role{Name: role.OwnerName, Version: 1}) users := newMockUserRepo() uc := useCases.NewCreateUserUseCase(users, roles, &stubHasher{}, newMockMailRepo(), "https://cms.example.com", discardRecorder) if _, err := uc.CreateUser(testCtx, useCases.CreateUserCommand{Username: "first", Password: "password1", Email: "first@renew.example"}); !errors.Is(err, role.ErrRoleNotFound) { t.Fatalf("expected ErrRoleNotFound, got %v", err) } }) } func TestUsersAPIListUsersAndInvitesUseCase(t *testing.T) { t.Run("roles are read once and resolved per row", func(t *testing.T) { w := newUsersAPIWorld(t) w.roles.calls = map[string]int{} entries, err := w.container.ListUsersAndInvitesUseCase.ListUsersAndInvites() if err != nil { t.Fatal(err) } if w.roles.calls["List"] != 1 || w.roles.calls["Get"] != 0 || w.roles.calls["GetByName"] != 0 { t.Errorf("expected one List call, got %v", w.roles.calls) } byKey := map[string]useCases.DirectoryEntry{} for _, entry := range entries { byKey[entry.Kind+strconv.Itoa(int(entry.ID))] = entry } if owner := byKey["user3"]; owner.Role == nil || owner.Role.Name != "Owner" || !owner.Role.BuiltIn || !owner.Role.Locked || owner.Role.ID != ownerRoleID { t.Errorf("unexpected owner entry %+v", owner) } if byKey["user13"].Role != nil || byKey["user14"].Role != nil { t.Errorf("users without a usable role must show no role") } if byKey["invite2"].Role == nil || byKey["invite2"].Role.Name != "Contributor" || byKey["invite1"].Role.Name != "Writer" { t.Errorf("invites must carry their role") } if _, present := byKey["invite4"]; present { t.Errorf("accepted invites are history") } }) t.Run("a role list failure is an error", func(t *testing.T) { w := newUsersAPIWorld(t) w.roles.fail.failNext("List") if _, err := w.container.ListUsersAndInvitesUseCase.ListUsersAndInvites(); !errors.Is(err, errRepositoryFailure) { t.Fatalf("unexpected %v", err) } }) } func TestUsersAPIResolution(t *testing.T) { t.Run("role id zero and a deleted role resolve to nothing", func(t *testing.T) { w := newUsersAPIWorld(t) for _, id := range []uint32{uaNoRole, uaGhostRole} { account := w.users.users[id] if _, err := w.container.GetMyAccessUseCase.Get(account); !errors.Is(err, role.ErrRoleNotFound) { t.Errorf("user %d: expected ErrRoleNotFound, got %v", id, err) } grants, err := w.container.CheckPermissionUseCase.Grants(account) if err != nil || len(grants.Effective()) != 0 { t.Errorf("user %d: expected an empty grant set, got %v, %v", id, grants.Effective(), err) } if _, err := w.container.CheckPermissionUseCase.Require(account, "content.read"); !errors.Is(err, role.ErrMissingPermission) { t.Errorf("user %d: expected a missing permission, got %v", id, err) } } }) } func TestUsersAPIRoleMembers(t *testing.T) { t.Run("members count users and open invites with one bulk call", func(t *testing.T) { w := newUsersAPIWorld(t) w.roles.calls = map[string]int{} w.users.getAllCalls = 0 listed, err := w.container.ListRolesUseCase.List() if err != nil { t.Fatal(err) } if w.roles.calls["MemberCounts"] != 1 || w.users.getAllCalls != 0 { t.Errorf("expected one MemberCounts call and no GetAll, got %v and %d", w.roles.calls, w.users.getAllCalls) } want := map[string]int{"Owner": 1, "Admin": 3, "Writer": 2, "Contributor": 6, "Intern": 1, "Content lead": 1, "People manager": 1, "Viewer": 1, "Inviter": 1} for _, view := range listed { if view.Members != want[view.Name] { t.Errorf("%s: expected %d members, got %d", view.Name, want[view.Name], view.Members) } } }) for _, state := range []string{"pending", "expired"} { t.Run("a role with only a "+state+" invite cannot be deleted, then can be moved and deleted", func(t *testing.T) { w := newUsersAPIWorld(t) temp := w.createTemp("content.read=own") now := time.Now() expires := now.Add(48 * time.Hour) if state == "expired" { expires = now.Add(-time.Hour) } w.invites.seed(invite.Invite{Email: "temp@renew.example", HashedCode: "hashed:t", InvitedBy: uaAdmin, RoleID: temp, CreatedAt: now.Add(-time.Hour), ExpiresAt: expires}) err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}) if !errors.Is(err, role.ErrRoleHasMembers) { t.Fatalf("expected ErrRoleHasMembers, got %v", err) } moved, err := w.container.ReassignRoleMembersUseCase.Reassign(testCtx, useCases.ReassignRoleMembersCommand{Caller: w.caller(uaAdmin), SourceID: temp, TargetID: w.id("Intern")}) if err != nil || moved != 1 { t.Fatalf("expected one moved invite, got %d, %v", moved, err) } if w.invites.invites[5].RoleID != w.id("Intern") { t.Errorf("the invite must now be for Intern") } last := w.recorder.events[len(w.recorder.events)-1] if last.Action != audit.ActionRoleReassign || last.Params[audit.ParamMoved] != 1 { t.Errorf("unexpected reassign event %+v", last) } if err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}); err != nil { t.Fatalf("expected the delete to pass, got %v", err) } }) } t.Run("moved counts users and invites together", func(t *testing.T) { w := newUsersAPIWorld(t) moved, err := w.container.ReassignRoleMembersUseCase.Reassign(testCtx, useCases.ReassignRoleMembersCommand{Caller: w.caller(uaAdmin), SourceID: w.id("Contributor"), TargetID: w.id("Intern")}) if err != nil || moved != 6 { t.Fatalf("expected 6, got %d, %v", moved, err) } }) t.Run("an accepted invite does not block the delete", func(t *testing.T) { w := newUsersAPIWorld(t) temp := w.createTemp("content.read=own") accepted := time.Now() w.invites.seed(invite.Invite{Email: "done@renew.example", HashedCode: "hashed:d", InvitedBy: uaAdmin, RoleID: temp, CreatedAt: accepted, ExpiresAt: accepted, AcceptedAt: &accepted}) if err := w.container.DeleteRoleUseCase.Delete(testCtx, useCases.DeleteRoleCommand{Caller: w.caller(uaAdmin), ID: temp}); err != nil { t.Fatalf("expected the delete to pass, got %v", err) } }) } func TestUsersAPIActivityVocabulary(t *testing.T) { t.Run("the new actions, field and params validate", func(t *testing.T) { for _, action := range []string{audit.ActionUserChangeRole, audit.ActionUserTransferOwnership} { event := audit.NewEvent(action, audit.CategoryUsers) event.Actor = audit.SystemActor() event.Source = audit.SourceWeb event.Changes = append(event.Changes, audit.TextChange(audit.FieldRole, "a", "b")) event.Params[audit.ParamRole] = "Writer" event.Params[audit.ParamRoleID] = uint32(3) if err := event.Validate(); err != nil { t.Errorf("%s: %v", action, err) } } if audit.ActionUserChangeRole != "user.change_role" || audit.ActionUserTransferOwnership != "user.transfer_ownership" || audit.FieldRole != "role" || audit.ParamRole != "role" || audit.ParamRoleID != "role_id" { t.Errorf("unexpected vocabulary") } }) }