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:
Florian Sylvain committed 2026-10-29 16:28:25 +01:00
1 parent 5d6f378fce
commit b39a41be88
124 files changed
+8816 -523

No files matched your search

+1
View File
@@ -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",
+1 -1
View File
@@ -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"},
+2 -2
View File
@@ -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},
+288
View File
@@ -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)
})
}
+23
View File
@@ -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
+1
View File
@@ -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)