feat: Users API, ownership transfer and removal of the legacy role string
This commit is contained in:
1 parent
094df63a7f
commit
80f7037d3a
124 files changed
+4644
-824
No files matched your search
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user