feat: /v1/role list, get, create, update and delete. Reassign members on delete. Write-time validation (ValidateGrants, duplicates, typed keys against real content types). Name uniqueness (role_name_taken), role_locked, role_has_members, role_conflict on version mismatch. No-escalation: nobody grants a permission they don't hold, and roles.edit_owner_only guards the Owner role. Activity events for role changes. Typed-key exposure in /auth/me. Cleanup of typed rows when a content type is deleted. | Backend only. Will carry the deferred ListGrants decision.

This commit is contained in:
Florian Sylvain committed 2026-10-27 00:45:24 +01:00
1 parent a043f7f41a
commit 094df63a7f
51 files changed
+4822 -115

No files matched your search

+332
View File
@@ -0,0 +1,332 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"encoding/json"
"net/http"
"reflect"
"strconv"
"strings"
"testing"
)
func TestRolesAPIDeleting(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
want string
}{
{"Owner deletes Intern", raOwner, "Intern", "204"},
{"Admin deletes Intern", raAdmin, "Intern", "204"},
{"Admin deletes Contributor", raAdmin, "Contributor", "409 role_has_members"},
{"Owner deletes Writer", raOwner, "Writer", "409 role_built_in"},
{"Owner deletes Admin", raOwner, "Admin", "409 role_built_in"},
{"Owner deletes Owner", raOwner, "Owner", "403 role_locked"},
{"Admin deletes Owner", raAdmin, "Owner", "403 role_locked"},
{"Admin deletes 999", raAdmin, "Missing", "404 role_not_found"},
{"Role manager deletes Role manager", raRoleManager, "Role manager", "403 own_role"},
{"Role manager deletes Content lead", raRoleManager, "Content lead", "escalation"},
{"Role manager deletes Intern", raRoleManager, "Intern", "204"},
}
for _, c := range rows {
t.Run("Deleting a role: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodDelete, world.path(c.target), "")
assertRolesAPIResult(t, rec, c.want)
_, exists := world.roles.roles[world.ids[c.target]]
if c.want == "204" {
if exists || rec.Body.Len() != 0 {
t.Fatal("the role must be gone and the body empty")
}
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodGet, world.path(c.target), ""), "404 role_not_found")
return
}
if c.target != "Missing" && !exists {
t.Fatal("a refused delete deletes nothing")
}
})
}
t.Run("a delete never touches content", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.seedArticle(7, true)
article := world.articles.articles[7]
article.CreatedBy = raContributorOne
world.articles.articles[7] = article
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "204")
if world.articles.articles[7].CreatedBy != raContributorOne {
t.Fatal("content keeps its created_by")
}
})
t.Run("a member appearing meanwhile answers role_has_members", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNextWith("Delete", role.ErrRoleHasMembers)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Intern"), ""), "409 role_has_members")
})
}
func TestRolesAPIReassigning(t *testing.T) {
target := func(w *rolesAPIWorld, name string) string {
return `{"target_role_id": ` + strconv.Itoa(int(w.ids[name])) + `}`
}
rows := []struct {
name string
caller uint32
source string
body func(*rolesAPIWorld) string
want string
}{
{"Admin moves Contributor to Writer", raAdmin, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
{"Admin moves Intern to Writer", raAdmin, "Intern", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "200"},
{"Role manager moves to Admin", raRoleManager, "Contributor", func(w *rolesAPIWorld) string { return target(w, "Admin") }, "escalation"},
{"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 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"},
{"Admin moves from 999", raAdmin, "Missing", func(w *rolesAPIWorld) string { return target(w, "Writer") }, "404 role_not_found"},
{"Role manager moves from their own role", raRoleManager, "Role manager", func(w *rolesAPIWorld) string { return target(w, "Intern") }, "403 own_role"},
}
for _, c := range rows {
t.Run("Moving members: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPost, world.path(c.source)+"/reassign", c.body(world))
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.want != "200" && len(world.members("Contributor")) != 4 {
t.Fatal("nobody may be moved by a refusal")
}
})
}
t.Run("Moving members, then deleting", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer"))
var moved struct {
Moved int `json:"moved"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &moved); err != nil || rec.Code != http.StatusOK || moved.Moved != 4 {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if got := world.members("Writer"); !reflect.DeepEqual(got, []uint32{raWriter, 41, 42, 43, 44}) {
t.Fatalf("unexpected Writer members %v", got)
}
account := world.users.users[41]
if account.TokenVersion != 0 || account.Password != "" || account.IsVerified != true {
t.Fatalf("only role_id may change: %+v", account)
}
assertRolesAPIResult(t, world.do(t, 41, http.MethodGet, "/v1/auth/me", ""), "200")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, world.path("Contributor"), ""), "204")
actions := world.recorder.actions()
if !reflect.DeepEqual(actions, []string{"role.reassign", "role.delete"}) {
t.Fatalf("unexpected events %v", actions)
}
})
t.Run("Moving nobody records no event", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPost, world.path("Intern")+"/reassign", target(world, "Writer"))
if rec.Code != http.StatusOK || strings.TrimSpace(rec.Body.String()) != `{"moved":0}` || len(world.recorder.events) != 0 {
t.Fatalf("unexpected %d %q events=%d", rec.Code, rec.Body.String(), len(world.recorder.events))
}
})
t.Run("A repository failure moves nobody, answers 500 and records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("ReassignMembers")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, world.path("Contributor")+"/reassign", target(world, "Writer")), "500 Internal server error")
if len(world.members("Contributor")) != 4 || len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed {
t.Fatalf("unexpected state, events=%+v", world.recorder.events)
}
})
}
func TestRolesAPIResetting(t *testing.T) {
edited := func(t *testing.T, world *rolesAPIWorld, name string) {
t.Helper()
body := rolesAPIUpdateBody(name, "custom", 1, "content.read=all")
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path(name), body), "200")
world.recorder.events = nil
}
for _, name := range []string{"Writer", "Admin"} {
t.Run("Resetting a built-in role to its defaults: Owner resets "+name+", edited", func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, name)
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path(name)+"/reset", `{"version": 2}`), http.StatusOK)
want := role.Role{Grants: role.BuiltInRoles()[map[string]int{"Admin": 1, "Writer": 2}[name]].Grants}.EffectiveGrants()
if body.Version != 3 || body.Name != name || !body.BuiltIn || body.Locked || body.Description != "" || !reflect.DeepEqual(body.Grants, want) {
t.Fatalf("unexpected role %+v", body)
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.edit" || world.recorder.events[0].Target.Name != name {
t.Fatalf("expected one role.edit event, got %+v", world.recorder.events)
}
})
}
t.Run("Resetting a built-in role to its defaults: a pristine role is a no-op, then a second reset too", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 1}`), http.StatusOK)
if body.Version != 1 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected %+v", body)
}
edited(t, world, "Writer")
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
again := decodeRolesAPIBody(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 3}`), http.StatusOK)
if again.Version != 3 {
t.Fatalf("a second reset is a no-op, got version %d", again.Version)
}
})
t.Run("Resetting a built-in role to its defaults: members see the seeded grants on their next request", func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, "Writer")
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusForbidden)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`), "200")
assertAPIKeyStatus(t, world.do(t, raWriter, http.MethodGet, "/v1/builtin-type", ""), http.StatusOK)
})
refusals := []struct {
name string
caller uint32
target string
version string
want string
}{
{"Owner, Writer, stale", raOwner, "Writer", "1", "409 role_conflict"},
{"Owner, Owner", raOwner, "Owner", "1", "403 role_locked"},
{"Owner, Contributor", raOwner, "Contributor", "1", "400 not_built_in"},
{"Owner, 999", raOwner, "Missing", "1", "404 role_not_found"},
{"Admin, Writer", raAdmin, "Writer", "2", "gate:roles.edit_owner_only"},
{"Viewer, Writer", raViewer, "Writer", "2", "gate:roles.manage"},
}
for _, c := range refusals {
t.Run("Resetting a built-in role to its defaults: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, "Writer")
assertRolesAPIResult(t, world.do(t, c.caller, http.MethodPost, world.path(c.target)+"/reset", `{"version": `+c.version+`}`), c.want)
if len(world.recorder.events) != 0 {
t.Fatal("a refused reset records nothing")
}
})
}
t.Run("Resetting a built-in role to its defaults: a missing version is a validation error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyStatus(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{}`), http.StatusBadRequest)
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, world.path("Writer")+"/reset", `{`), http.StatusBadRequest, apperr.BodyErrorMessage)
})
t.Run("Resetting a built-in role to its defaults: the API key of the Owner is refused", func(t *testing.T) {
world := newRolesAPIWorld(t)
edited(t, world, "Writer")
raw, _ := world.issueKey(raOwner, "owner key")
assertRolesAPIResult(t, world.withKey(t, http.MethodPost, world.path("Writer")+"/reset", `{"version": 2}`, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
})
}
func TestRolesAPIActivity(t *testing.T) {
t.Run("Activity events for roles", func(t *testing.T) {
world := newRolesAPIWorld(t)
created := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "Reviewer", "grants": `+rolesAPIGrantsJSON("content.read=all", "content.update=own", "media.view=all")+`}`), http.StatusCreated)
path := "/v1/role/" + strconv.Itoa(int(created.ID))
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, path, rolesAPIUpdateBody("Reviewers", "", 1, "content.read=all", "content.update=all", "content.delete=own")), "200")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, path, ""), "204")
events := world.recorder.events
if !reflect.DeepEqual(world.recorder.actions(), []string{"role.create", "role.edit", "role.delete"}) {
t.Fatalf("unexpected events %v", world.recorder.actions())
}
for _, event := range events {
if event.Category != audit.CategoryRoles || event.Outcome != audit.OutcomeSuccess || event.Target.Type != "role" || event.Target.ID != strconv.Itoa(int(created.ID)) {
t.Fatalf("unexpected event %+v", event)
}
}
edit := events[1]
if edit.Target.Name != "Reviewers" ||
!reflect.DeepEqual(edit.Params[audit.ParamAdded], grantRows("content.delete=own")) ||
!reflect.DeepEqual(edit.Params[audit.ParamRemoved], grantRows("media.view=all")) ||
!reflect.DeepEqual(edit.Params[audit.ParamChanged], []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
t.Fatalf("unexpected edit params %v", edit.Params)
}
if events[2].Params[audit.ParamMembers] != 0 || events[2].Target.Name != "Reviewers" {
t.Fatalf("unexpected delete event %+v", events[2])
}
})
}
func TestRolesAPIAuthMe(t *testing.T) {
t.Run("/auth/me exposes typed grants", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec, body := world.me(t, raEventsEditor)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d", rec.Code)
}
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
if !reflect.DeepEqual(body.Access.Permissions, want) {
t.Fatalf("expected %v, got %v", want, body.Access.Permissions)
}
})
}
func TestRolesAPIContentTypes(t *testing.T) {
createEvents := `{"key": "events", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`
t.Run("Deleting a content type removes its typed grants", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), "204")
editor := world.role("Events editor")
if !reflect.DeepEqual(editor.Grants, grantRows("content.read=all", "content.update:venues=all")) || editor.Version != 2 {
t.Fatalf("unexpected role %v version %d", editor.Grants, editor.Version)
}
if world.role("Contributor").Version != 1 {
t.Fatal("a role without typed rows keeps its version")
}
_, body := world.me(t, raEventsEditor)
if !reflect.DeepEqual(body.Access.Permissions, grantRows("content.read=all", "content.update:venues=all")) {
t.Fatalf("unexpected permissions %v", body.Access.Permissions)
}
})
t.Run("A refused content type delete keeps the typed grants", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.entries.entries[1] = entryOfType(raEventsTypeID)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/content-type/events", ""), http.StatusConflict, apperr.ContentTypeHasEntriesErrorMessage)
if world.role("Events editor").Version != 1 {
t.Fatal("the role must be unchanged")
}
})
t.Run("A content type created with the key of a deleted one starts clean", func(t *testing.T) {
world := newRolesAPIWorld(t)
delete(world.types.types, raEventsTypeID)
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.update:events=none")})
rec := world.do(t, raAdmin, http.MethodPost, "/v1/content-type", createEvents)
if rec.Code != http.StatusCreated {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if len(world.roles.roles[stale.ID].Grants) != 0 {
t.Fatal("the stale row must be removed")
}
})
t.Run("Content type keys do not change", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPut, "/v1/content-type/events", `{"key": "other", "name": "Events", "fields": [{"key": "title", "label": "Title", "type": "text", "required": true}]}`)
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.types.types[raEventsTypeID].Key != "events" || world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
t.Fatal("an update never rewrites the key nor the typed rows")
}
})
}