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

+214
View File
@@ -0,0 +1,214 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"bytes"
"context"
"errors"
"log"
"net/http"
"os"
"reflect"
"testing"
)
func TestRoleRepositoryRolesAPIHardening(t *testing.T) {
t.Run("ReassignMembers refuses a target that does not exist and moves nobody", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
createUserRow(t, db, 1, "", &source.ID)
moved, err := repo.ReassignMembers(source.ID, 9999)
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 {
t.Fatalf("expected ErrInvalidReassign, got %d %v", moved, err)
}
if roleIDOf(userRow(t, db, 1)) != source.ID {
t.Fatal("the member must stay")
}
})
t.Run("ReassignMembers from an empty role to an existing one is a plain zero", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
writer := storedRole(t, db, "Writer")
moved, err := repo.ReassignMembers(source.ID, writer.ID)
if err != nil || moved != 0 {
t.Fatalf("expected 0 nil, got %d %v", moved, err)
}
})
t.Run("Create re-checks the name case-insensitively", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
for _, name := range []string{"contributor", "CONTRIBUTOR", " Contributor "} {
_, err := repo.Create(role.Role{Name: name, Version: 1})
if !errors.Is(err, role.ErrRoleNameTaken) {
t.Errorf("%q: expected ErrRoleNameTaken, got %v", name, err)
}
}
listed, _ := repo.List()
if len(listed) != 4 {
t.Fatalf("no role may be added, got %d", len(listed))
}
})
t.Run("Save re-checks the name against other roles only", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
first := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
second := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
clash := second
clash.Name = "contributor"
if _, err := repo.Save(clash); !errors.Is(err, role.ErrRoleNameTaken) {
t.Fatalf("expected ErrRoleNameTaken, got %v", err)
}
if stored, _ := repo.Get(second.ID); stored.Name != "Intern" || stored.Version != 1 {
t.Fatalf("the role must be unchanged: %+v", stored)
}
recased := first
recased.Name = "CONTRIBUTOR"
if saved, err := repo.Save(recased); err != nil || saved.Name != "CONTRIBUTOR" {
t.Fatalf("a role may change the case of its own name, got %+v %v", saved, err)
}
})
t.Run("Delete with the row lock still removes a memberless role and refuses one with a member", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
kept := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
gone := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
createUserRow(t, db, 1, "", &kept.ID)
if err := repo.Delete(kept.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
}
if err := repo.Delete(gone.ID); err != nil {
t.Fatal(err)
}
})
}
func TestRolesAPIHardeningUseCases(t *testing.T) {
t.Run("stale typed rows never show in a view and a GET body stays a valid PUT body", func(t *testing.T) {
world := newRolesAPIWorld(t)
stale, _ := world.roles.Create(role.Role{Name: "Stale", Version: 1, Grants: grantRows("content.read=all", "content.update:ghost=all", "content.update:events=own")})
got, err := world.container.GetRoleUseCase.Get(stale.ID)
if err != nil {
t.Fatal(err)
}
want := []string{"content.read=all", "content.update:events=own"}
if !reflect.DeepEqual(rolesAPIGrantKeys(got.Grants), want) {
t.Fatalf("unexpected grants %v", rolesAPIGrantKeys(got.Grants))
}
listed, err := world.container.ListRolesUseCase.List()
if err != nil {
t.Fatal(err)
}
for _, item := range listed {
if item.ID == stale.ID && !reflect.DeepEqual(rolesAPIGrantKeys(item.Grants), want) {
t.Fatalf("unexpected listed grants %v", rolesAPIGrantKeys(item.Grants))
}
}
updated, err := world.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{Caller: world.caller(raAdmin), ID: stale.ID, Name: "Stale", Grants: got.Grants, Version: got.Version})
if err != nil {
t.Fatalf("a GET body must be accepted, got %v", err)
}
if updated.Version != 1 || len(world.recorder.events) != 0 {
t.Fatalf("a body identical to the view is a no-op: version %d events %d", updated.Version, len(world.recorder.events))
}
})
t.Run("a failed content type create does not bump any role", func(t *testing.T) {
world := newRolesAPIWorld(t)
delete(world.types.types, raEventsTypeID)
world.types.fail.failNext("Create")
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err == nil {
t.Fatal("expected the create to fail")
}
if world.roles.calls["RemoveTypedGrants"] != 0 || world.role("Events editor").Version != 1 {
t.Fatal("no role may be touched by a failed create")
}
})
t.Run("a failed cleanup after a create is reported", func(t *testing.T) {
world := newRolesAPIWorld(t)
delete(world.types.types, raEventsTypeID)
world.roles.fail.failNext("RemoveTypedGrants")
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err == nil {
t.Fatal("expected the failure to be reported")
}
})
t.Run("a target that vanished is an invalid reassign and records nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNextWith("ReassignMembers", role.ErrInvalidReassign)
moved, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"])
if !errors.Is(err, role.ErrInvalidReassign) || moved != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %d %v events=%d", moved, err, len(world.recorder.events))
}
})
t.Run("a reassign failure event carries from, to and a zero moved", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("ReassignMembers")
_, _ = world.reassign(raAdmin, "Contributor", world.ids["Writer"])
params := world.recorder.events[0].Params
if params[audit.ParamFrom] != world.ids["Contributor"] || params[audit.ParamTo] != world.ids["Writer"] || params[audit.ParamMoved] != 0 {
t.Fatalf("unexpected params %+v", params)
}
})
t.Run("a member count failure while deleting a role is reported and deletes nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("GetByName")
err := world.remove(raAdmin, "Intern")
if !errors.Is(err, errRepositoryFailure) {
t.Fatalf("expected the repository failure, got %v", err)
}
if world.roles.calls["Delete"] != 0 {
t.Fatal("nothing may be deleted")
}
})
t.Run("a content type that references itself can be deleted but one referenced by another cannot", func(t *testing.T) {
world := newRolesAPIWorld(t)
selfRef := validContentTypeFields()
selfRef = append(selfRef, contenttype.Field{Key: "parent", Label: "Parent", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "events", Kind: contenttype.RelationOneToMany}})
events := world.types.types[raEventsTypeID]
events.Fields = selfRef
world.types.types[raEventsTypeID] = events
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err != nil {
t.Fatalf("a self reference must not block the delete, got %v", err)
}
venues := world.types.types[raVenuesTypeID]
venues.Fields = append(validContentTypeFields(), contenttype.Field{Key: "owner", Label: "Owner", Type: contenttype.TypeRelation, Options: []string{}, Relation: &contenttype.Relation{ContentType: "other", Kind: contenttype.RelationOneToMany}})
world.types.types[raVenuesTypeID] = venues
world.types.types[7] = contenttype.ContentType{ID: 7, Key: "other", Name: "Other", Fields: validContentTypeFields()}
if err := world.container.DeleteContentTypeUseCase.Delete("other"); !errors.Is(err, contenttype.ErrReferenced) {
t.Fatalf("expected ErrReferenced, got %v", err)
}
})
}
func TestEscalationErrorMessage(t *testing.T) {
err := role.EscalationError{Key: "content.update:events"}
if err.Error() != role.ErrEscalation.Error()+": content.update:events" || !errors.Is(err, role.ErrEscalation) {
t.Fatalf("unexpected error %q", err.Error())
}
}
func TestRolesAPIHardeningQuietSuccess(t *testing.T) {
world := newRolesAPIWorld(t)
var sink bytes.Buffer
log.SetOutput(&sink)
defer log.SetOutput(os.Stderr)
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if rec.Code != http.StatusOK || sink.Len() != 0 {
t.Fatalf("a served response must not log: %d %q", rec.Code, sink.String())
}
}