feat: Users API, ownership transfer and removal of the legacy role string

This commit is contained in:
Florian Sylvain committed 2026-10-27 03:08:15 +01:00
1 parent 094df63a7f
commit 80f7037d3a
124 files changed
+4644 -824

No files matched your search

+715
View File
@@ -0,0 +1,715 @@
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")
}
})
}