refac: DELETE /v1/user/{id}?expected_role_id=N, user.remove audit event, 409 target_role_not_found, GetAll returns its error, Own-scope visibility filter moved into repos, JSON tags removed from use-case types
This commit is contained in:
1 parent
5d6f378fce
commit
b39a41be88
124 files changed
+8816
-523
No files matched your search
@@ -51,6 +51,7 @@ func routerRoutes(t *testing.T) map[string]bool {
|
||||
|
||||
var routesNotYetInTheCatalogue = []string{
|
||||
"DELETE /v1/role/{id}",
|
||||
"DELETE /v1/user/{id}",
|
||||
"GET /v1/user/lookup",
|
||||
"GET /v1/permission",
|
||||
"GET /v1/role",
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestRolesAPIReassigning(t *testing.T) {
|
||||
{"Role manager moves to Intern", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "200"},
|
||||
{"Admin moves to Owner", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Owner") }, "400 invalid_reassign"},
|
||||
{"Admin moves to the same role", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Contributor") }, "400 invalid_reassign"},
|
||||
{"Admin moves to 999", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{"target_role_id": 999}` }, "400 invalid_reassign"},
|
||||
{"Admin moves to 999", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{"target_role_id": 999}` }, "409 target_role_not_found"},
|
||||
{"Admin sends no target", raAdmin, "Contributor", func(*rolesAPIWorld) string { return `{}` }, "400"},
|
||||
{"Admin moves from Writer", raAdmin, "Writer", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "409 role_built_in"},
|
||||
{"Admin moves from Owner", raAdmin, "Owner", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 role_locked"},
|
||||
|
||||
@@ -732,7 +732,7 @@ func TestReassignRoleMembersUseCase(t *testing.T) {
|
||||
t.Fatal("only role_id may change")
|
||||
}
|
||||
event := world.recorder.events[0]
|
||||
if event.Action != "role.reassign" || event.Params[audit.ParamFrom] != world.ids["Contributor"] || event.Params[audit.ParamTo] != world.ids["Writer"] || event.Params[audit.ParamMoved] != 4 {
|
||||
if event.Action != "role.reassign" || event.Params[audit.ParamFrom] != world.ids["Contributor"] || event.Params[audit.ParamTo] != world.ids["Writer"] || event.Params[audit.ParamMoved] != 4 || event.Params[audit.ParamToName] != "Writer" {
|
||||
t.Fatalf("unexpected event %+v", event)
|
||||
}
|
||||
if err := world.remove(raAdmin, "Contributor"); err != nil {
|
||||
@@ -758,7 +758,7 @@ func TestReassignRoleMembersUseCase(t *testing.T) {
|
||||
{"above the caller's grants", raRoleManager, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Admin"] }, role.ErrEscalation},
|
||||
{"owner as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Owner"] }, role.ErrInvalidReassign},
|
||||
{"same role as target", raAdmin, "Contributor", func(w *rolesAPIWorld) uint32 { return w.ids["Contributor"] }, role.ErrInvalidReassign},
|
||||
{"unknown target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return raUnknownRoleID }, role.ErrInvalidReassign},
|
||||
{"unknown target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return raUnknownRoleID }, role.ErrReassignTargetNotFound},
|
||||
{"zero target", raAdmin, "Contributor", func(*rolesAPIWorld) uint32 { return 0 }, role.ErrInvalidReassign},
|
||||
{"built-in source", raAdmin, "Writer", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrBuiltInRole},
|
||||
{"owner source", raAdmin, "Owner", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrRoleLocked},
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/api/pkg/apperr"
|
||||
"RenewCMS/internal/domain/article"
|
||||
"RenewCMS/internal/domain/audit"
|
||||
"RenewCMS/internal/domain/role"
|
||||
"RenewCMS/internal/domain/user"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"RenewCMS/internal/infrastructure/useCases"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const removeManagerID uint32 = 60
|
||||
|
||||
func newRemoveWorld(t *testing.T) *usersAPIWorld {
|
||||
t.Helper()
|
||||
w := newUsersAPIWorld(t)
|
||||
manager, _ := w.roles.Create(role.Role{Name: "Remover", Version: 1, Grants: grantRows("users.view=all", "users.remove=all", "content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all")})
|
||||
w.ids["Remover"] = manager.ID
|
||||
w.addMember(removeManagerID, "remover", "remover@renew.example", manager.ID, true)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *usersAPIWorld) remove(t *testing.T, caller uint32, target string, expected string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
path := "/v1/user/" + target
|
||||
if expected != "" {
|
||||
path += "?expected_role_id=" + expected
|
||||
}
|
||||
return w.do(t, caller, http.MethodDelete, path, "")
|
||||
}
|
||||
|
||||
func TestRemoveUserAnswers(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
target string
|
||||
expected string
|
||||
status int
|
||||
code string
|
||||
key string
|
||||
gone bool
|
||||
}{
|
||||
{"Owner removes Ines", uaOwner, "9", "Writer", 204, "", "", true},
|
||||
{"Admin removes Ines", uaAdmin, "9", "Writer", 204, "", "", true},
|
||||
{"People manager removes an Intern", removeManagerID, "46", "Intern", 204, "", "", true},
|
||||
{"Owner with a stale role", uaOwner, "9", "Admin", 409, apperr.UserRoleChangedErrorMessage, "", false},
|
||||
{"absent expected_role_id", uaOwner, "9", "", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"zero expected_role_id", uaOwner, "9", "0", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"negative expected_role_id", uaOwner, "9", "-1", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"text expected_role_id", uaOwner, "9", "abc", 400, apperr.InvalidRoleErrorMessage, "", false},
|
||||
{"bad id", uaOwner, "abc", "Writer", 400, apperr.IdUint32ErrorMessage, "", false},
|
||||
{"unknown user", uaOwner, "9999", "Writer", 404, apperr.UserNotFoundErrorMessage, "", false},
|
||||
{"Owner removes self", uaOwner, "3", "Owner", 403, apperr.OwnRoleErrorMessage, "", false},
|
||||
{"Admin removes self", uaAdmin, "5", "Admin", 403, apperr.OwnRoleErrorMessage, "", false},
|
||||
{"Admin removes the Owner", uaAdmin, "3", "Owner", 403, apperr.RoleLockedErrorMessage, "", false},
|
||||
{"People manager removes a Content lead", removeManagerID, "45", "Content lead", 403, "escalation", "content.update", false},
|
||||
{"People manager removes an Admin", removeManagerID, "5", "Admin", 403, "escalation", "", false},
|
||||
{"Writer lacks users.remove", uaWriter, "46", "Intern", 403, "missing_permission", "", false},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
expected := row.expected
|
||||
if _, err := strconv.Atoi(expected); err != nil && expected != "" {
|
||||
if id, ok := w.ids[expected]; ok {
|
||||
expected = strconv.FormatUint(uint64(id), 10)
|
||||
}
|
||||
}
|
||||
targetID, _ := strconv.ParseUint(row.target, 10, 32)
|
||||
rec := w.remove(t, row.caller, row.target, expected)
|
||||
switch {
|
||||
case row.status == 204:
|
||||
if rec.Code != 204 || rec.Body.Len() != 0 {
|
||||
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
|
||||
}
|
||||
case row.code == "missing_permission":
|
||||
usersAPIAssertMissing(t, rec, "users.remove")
|
||||
case row.code == "escalation":
|
||||
assertEscalation(t, rec, row.key)
|
||||
default:
|
||||
assertRefusal(t, rec, row.status, row.code)
|
||||
}
|
||||
_, present := w.users.users[uint32(targetID)]
|
||||
if _, existed := newRemoveWorld(t).users.users[uint32(targetID)]; existed && present == row.gone {
|
||||
t.Errorf("target presence %v, gone expected %v", present, row.gone)
|
||||
}
|
||||
wantEvents := 0
|
||||
if row.gone {
|
||||
wantEvents = 1
|
||||
}
|
||||
if len(w.recorder.events) != wantEvents {
|
||||
t.Errorf("expected %d events, got %v", wantEvents, w.recorder.actions())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no cookie", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
rec := w.anonymous(t, http.MethodDelete, "/v1/user/9?expected_role_id="+w.idText("Writer"), "")
|
||||
usersAPIAssertPlain(t, rec, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
|
||||
if _, present := w.users.users[9]; !present {
|
||||
t.Error("nothing may be removed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveUserCheckOrder(t *testing.T) {
|
||||
t.Run("a stale role beats role_locked and escalation", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
assertRefusal(t, w.remove(t, uaAdmin, "3", w.idText("Writer")), 409, apperr.UserRoleChangedErrorMessage)
|
||||
rec := w.remove(t, removeManagerID, "45", w.idText("Admin"))
|
||||
assertRefusal(t, rec, 409, apperr.UserRoleChangedErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("own_role beats a stale role", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
assertRefusal(t, w.remove(t, uaAdmin, "5", w.idText("Writer")), 403, apperr.OwnRoleErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("missing_permission is answered before the target lookup", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
before := w.users.getCalls
|
||||
usersAPIAssertMissing(t, w.remove(t, uaWriter, "9", w.idText("Writer")), "users.remove")
|
||||
if w.users.getCalls != before+1 {
|
||||
t.Errorf("only the caller may be looked up, got %d extra Get calls", w.users.getCalls-before-1)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the id is read before expected_role_id", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
usersAPIAssertPlain(t, w.remove(t, uaOwner, "abc", ""), 400, apperr.IdUint32ErrorMessage)
|
||||
})
|
||||
|
||||
t.Run("expected_role_id is read before the target lookup", func(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
assertRefusal(t, w.remove(t, uaOwner, "9999", ""), 400, apperr.InvalidRoleErrorMessage)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRemoveUserRaceAnswers409(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.users.beforeDeleteIfRole = func() {
|
||||
stored := w.users.users[9]
|
||||
stored.RoleID = w.id("Admin")
|
||||
w.users.users[9] = stored
|
||||
}
|
||||
assertRefusal(t, w.remove(t, uaOwner, "9", w.idText("Writer")), 409, apperr.UserRoleChangedErrorMessage)
|
||||
if _, present := w.users.users[9]; !present || len(w.recorder.events) != 0 {
|
||||
t.Error("the user must remain and nothing be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserWriteFailure(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.users.fail.failNextWith("DeleteIfRole", errors.New("disk full"))
|
||||
usersAPIAssertPlain(t, w.remove(t, uaOwner, "9", w.idText("Writer")), 500, "Internal server error")
|
||||
if _, present := w.users.users[9]; !present {
|
||||
t.Error("the user must remain")
|
||||
}
|
||||
if len(w.recorder.events) != 1 || w.recorder.events[0].Action != audit.ActionUserRemove || w.recorder.events[0].Outcome != audit.OutcomeFailed {
|
||||
t.Errorf("expected one failed user.remove event, got %+v", w.recorder.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserAudit(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
if rec := w.remove(t, uaAdmin, "9", w.idText("Writer")); rec.Code != 204 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
if len(w.recorder.events) != 1 {
|
||||
t.Fatalf("expected one event, got %d", len(w.recorder.events))
|
||||
}
|
||||
event := w.recorder.events[0]
|
||||
if event.Action != "user.remove" || event.Category != audit.CategoryUsers || event.Outcome != audit.OutcomeSuccess {
|
||||
t.Errorf("unexpected event %+v", event)
|
||||
}
|
||||
if event.Target.Type != audit.TargetUser || event.Target.ID != "9" || event.Target.Name != "ines" {
|
||||
t.Errorf("unexpected target %+v", event.Target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserRevokesAccess(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.container.LookupUserNamesUseCase = useCases.NewLookupUserNamesUseCase(w.users)
|
||||
w.rebuildRouter()
|
||||
removedKey, _ := w.issueKey(uaWriter, "ines")
|
||||
otherKey, _ := w.issueKey(uaContributor, "other")
|
||||
if rec := w.do(t, uaWriter, http.MethodGet, "/v1/user/lookup?ids=9", ""); rec.Code == http.StatusUnauthorized {
|
||||
t.Fatalf("the session must work before the removal, got %d", rec.Code)
|
||||
}
|
||||
if rec := w.remove(t, uaOwner, "9", w.idText("Writer")); rec.Code != 204 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
usersAPIAssertPlain(t, w.do(t, uaWriter, http.MethodGet, "/v1/user/lookup?ids=9", ""), http.StatusUnauthorized, apperr.UnauthorizedErrorMessage)
|
||||
if rec := w.withKey(t, http.MethodGet, "/v1/user/lookup?ids=9", "", removedKey); rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("the removed user's key must answer 401, got %d", rec.Code)
|
||||
}
|
||||
if rec := w.withKey(t, http.MethodGet, "/v1/user/lookup?ids=9", "", otherKey); rec.Code == http.StatusUnauthorized {
|
||||
t.Errorf("another user's key must keep working")
|
||||
}
|
||||
if rec := w.do(t, uaContributor, http.MethodGet, "/v1/user/lookup?ids=9", ""); rec.Code == http.StatusUnauthorized {
|
||||
t.Errorf("another user's session must keep working")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserLeavesContentAndInvites(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
w.articles.articles[1] = article.Article{ID: 1, Title: "Kept", CreatedBy: uaWriter}
|
||||
invitesBefore, _ := w.invites.GetAll()
|
||||
if rec := w.remove(t, uaOwner, "9", w.idText("Writer")); rec.Code != 204 {
|
||||
t.Fatalf("unexpected %d", rec.Code)
|
||||
}
|
||||
if w.articles.articles[1].CreatedBy != uaWriter {
|
||||
t.Errorf("the article must keep its created_by")
|
||||
}
|
||||
invitesAfter, _ := w.invites.GetAll()
|
||||
if len(invitesAfter) != len(invitesBefore) {
|
||||
t.Errorf("invites must stay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserOnlyAcceptedUsers(t *testing.T) {
|
||||
w := newRemoveWorld(t)
|
||||
invites, _ := w.invites.GetAll()
|
||||
inviteID := invites[0].ID + 1000
|
||||
assertRefusal(t, w.remove(t, uaOwner, strconv.FormatUint(uint64(inviteID), 10), w.idText("Writer")), 404, apperr.UserNotFoundErrorMessage)
|
||||
if rec := w.do(t, uaOwner, http.MethodDelete, "/v1/user/invite/"+strconv.FormatUint(uint64(invites[0].ID), 10), ""); rec.Code != 204 {
|
||||
t.Errorf("invites stay cancellable, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveUserDeleteIfRolePersistence(t *testing.T) {
|
||||
db := openMigratedRolesDB(t)
|
||||
repo := persistence.NewUserRepository(db)
|
||||
admin, writer := storedRole(t, db, "Admin").ID, storedRole(t, db, "Writer").ID
|
||||
uaPersistedUser(t, db, 5, &admin, true)
|
||||
uaPersistedUser(t, db, 6, &writer, true)
|
||||
if err := repo.DeleteIfRole(5, writer); !errors.Is(err, user.ErrRoleChanged) {
|
||||
t.Fatalf("expected ErrRoleChanged, got %v", err)
|
||||
}
|
||||
if err := repo.DeleteIfRole(99, writer); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Fatalf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
if err := repo.DeleteIfRole(5, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := repo.Get(5); !errors.Is(err, user.ErrUserNotFound) {
|
||||
t.Errorf("user 5 must be gone, got %v", err)
|
||||
}
|
||||
if _, err := repo.Get(6); err != nil {
|
||||
t.Errorf("user 6 must remain, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReassignTargetNotFoundAndToName(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
contributor := w.idText("Contributor")
|
||||
path := "/v1/role/" + contributor + "/reassign"
|
||||
rows := []struct {
|
||||
name string
|
||||
caller uint32
|
||||
target string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{"missing target", uaOwner, "9999", 409, apperr.ReassignTargetNotFoundErrorMessage},
|
||||
{"itself", uaOwner, contributor, 400, apperr.InvalidReassignErrorMessage},
|
||||
{"locked", uaOwner, strconv.FormatUint(uint64(ownerRoleID), 10), 400, apperr.InvalidReassignErrorMessage},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
w := newUsersAPIWorld(t)
|
||||
assertRefusal(t, w.do(t, row.caller, http.MethodPost, path, `{"target_role_id": `+row.target+`}`), row.status, row.code)
|
||||
if len(w.recorder.events) != 0 || w.storedRoleID(41) != w.id("Contributor") {
|
||||
t.Error("a refusal moves nobody and records nothing")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("missing source", func(t *testing.T) {
|
||||
assertRefusal(t, w.do(t, uaOwner, http.MethodPost, "/v1/role/9999/reassign", `{"target_role_id": `+w.idText("Writer")+`}`), 404, apperr.RoleNotFoundErrorMessage)
|
||||
})
|
||||
}
|
||||
@@ -53,6 +53,8 @@ type mockUserRepo struct {
|
||||
updateRoleCalls int
|
||||
transferCalls int
|
||||
beforeUpdateRole func()
|
||||
deleteIfRoleCalls int
|
||||
beforeDeleteIfRole func()
|
||||
beforeTransfer func()
|
||||
beforeClearVerification func()
|
||||
knownRoles *mockRoleRepo
|
||||
@@ -162,6 +164,27 @@ func (m *mockUserRepo) Delete(id uint32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) DeleteIfRole(id uint32, roleID uint32) error {
|
||||
m.deleteIfRoleCalls++
|
||||
if err := m.fail.trigger("DeleteIfRole"); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.beforeDeleteIfRole != nil {
|
||||
hook := m.beforeDeleteIfRole
|
||||
m.beforeDeleteIfRole = nil
|
||||
hook()
|
||||
}
|
||||
stored, ok := m.users[id]
|
||||
if !ok {
|
||||
return user.ErrUserNotFound
|
||||
}
|
||||
if stored.RoleID != roleID {
|
||||
return user.ErrRoleChanged
|
||||
}
|
||||
delete(m.users, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) UpdateVerificationStatus(userId uint32, isVerified bool) (user.User, error) {
|
||||
if err := m.fail.trigger("UpdateVerificationStatus"); err != nil {
|
||||
return user.User{}, err
|
||||
|
||||
@@ -113,6 +113,7 @@ func (w *usersAPIWorld) wire() {
|
||||
hasher := &stubHasher{}
|
||||
url := "https://cms.example.com"
|
||||
c.ChangeUserRoleUseCase = useCases.NewChangeUserRoleUseCase(w.users, w.roles, w.types, w.recorder)
|
||||
c.RemoveUserUseCase = useCases.NewRemoveUserUseCase(w.users, w.roles, w.types, w.recorder)
|
||||
c.GetMyAccessUseCase = useCases.NewGetMyAccessUseCase(w.roles, w.users)
|
||||
c.TransferOwnershipUseCase = useCases.NewTransferOwnershipUseCase(w.users, w.roles, w.recorder)
|
||||
c.CreateInviteUseCase = useCases.NewCreateInviteUseCase(w.users, w.invites, w.roles, w.types, hasher, w.mail, url, w.recorder)
|
||||
|
||||
Reference in new issue
Block a user