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

+14 -2
View File
@@ -76,6 +76,12 @@ type UseCases struct {
GetContentTypeUseCase *useCases.GetContentTypeUseCase
UpdateContentTypeUseCase *useCases.UpdateContentTypeUseCase
DeleteContentTypeUseCase *useCases.DeleteContentTypeUseCase
ListRolesUseCase *useCases.ListRolesUseCase
GetRoleUseCase *useCases.GetRoleUseCase
CreateRoleUseCase *useCases.CreateRoleUseCase
UpdateRoleUseCase *useCases.UpdateRoleUseCase
DeleteRoleUseCase *useCases.DeleteRoleUseCase
ReassignRoleMembersUseCase *useCases.ReassignRoleMembersUseCase
ListBuiltinTypesUseCase *useCases.ListBuiltinTypesUseCase
GetBuiltinTypeUseCase *useCases.GetBuiltinTypeUseCase
LookupUserNamesUseCase *useCases.LookupUserNamesUseCase
@@ -380,11 +386,17 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
SetDefaultSiteUseCase: useCases.NewSetDefaultSiteUseCase(repos.SiteRepo, recorder),
DeleteSiteUseCase: useCases.NewDeleteSiteUseCase(repos.SiteRepo, recorder),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, time.Now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(repos.ContentTypeRepo, repos.RoleRepo, time.Now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(repos.ContentTypeRepo),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(repos.ContentTypeRepo),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, time.Now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(repos.ContentTypeRepo, repos.EntryRepo, repos.RoleRepo),
ListRolesUseCase: useCases.NewListRolesUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
GetRoleUseCase: useCases.NewGetRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo),
CreateRoleUseCase: useCases.NewCreateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
UpdateRoleUseCase: useCases.NewUpdateRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder, time.Now),
DeleteRoleUseCase: useCases.NewDeleteRoleUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ReassignRoleMembersUseCase: useCases.NewReassignRoleMembersUseCase(repos.RoleRepo, repos.UserRepo, repos.ContentTypeRepo, recorder),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
LookupUserNamesUseCase: useCases.NewLookupUserNamesUseCase(repos.UserRepo),
+249
View File
@@ -0,0 +1,249 @@
package role
import (
"RenewCMS/api/pkg/access"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
domain "RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
type Handler struct {
ListUseCase *useCases.ListRolesUseCase
GetUseCase *useCases.GetRoleUseCase
CreateUseCase *useCases.CreateRoleUseCase
UpdateUseCase *useCases.UpdateRoleUseCase
DeleteUseCase *useCases.DeleteRoleUseCase
ReassignUseCase *useCases.ReassignRoleMembersUseCase
}
func NewHandler(list *useCases.ListRolesUseCase, get *useCases.GetRoleUseCase, create *useCases.CreateRoleUseCase, update *useCases.UpdateRoleUseCase, deleteUseCase *useCases.DeleteRoleUseCase, reassign *useCases.ReassignRoleMembersUseCase) *Handler {
return &Handler{ListUseCase: list, GetUseCase: get, CreateUseCase: create, UpdateUseCase: update, DeleteUseCase: deleteUseCase, ReassignUseCase: reassign}
}
type GrantRequest struct {
Key string `json:"key" validate:"required,max=100"`
Scope string `json:"scope"`
}
type PostRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
}
type PutRole struct {
Name string `json:"name" validate:"required,max=100"`
Description string `json:"description" validate:"max=255"`
Grants []GrantRequest `json:"grants" validate:"dive"`
Version uint32 `json:"version" validate:"required,min=1"`
}
type ResetRole struct {
Version uint32 `json:"version" validate:"required,min=1"`
}
type ReassignRole struct {
TargetRoleID uint32 `json:"target_role_id" validate:"required"`
}
func toGrants(requested []GrantRequest) []domain.Grant {
grants := make([]domain.Grant, 0, len(requested))
for _, grant := range requested {
grants = append(grants, domain.Grant{Key: grant.Key, Scope: domain.Scope(grant.Scope)})
}
return grants
}
func parseRoleId(r *http.Request) (uint32, error) {
id, err := strconv.ParseUint(chi.URLParam(r, "id"), 10, 32)
return uint32(id), err
}
func decodeRequest(w http.ResponseWriter, r *http.Request, request any) bool {
if err := json.NewDecoder(r.Body).Decode(request); err != nil {
http.Error(w, apperr.BodyErrorMessage, http.StatusBadRequest)
return false
}
if err := validator.Validate.Struct(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return false
}
return true
}
func readId(w http.ResponseWriter, r *http.Request) (uint32, bool) {
id, err := parseRoleId(r)
if err != nil {
http.Error(w, apperr.IdUint32ErrorMessage, http.StatusBadRequest)
return 0, false
}
return id, true
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, err := json.Marshal(payload)
if err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
if status != http.StatusOK {
w.WriteHeader(status)
}
if _, err := w.Write(body); err != nil {
log.Printf("Failed to write response: %v", err)
}
}
type refusal struct {
cause error
status int
message string
}
var refusals = []refusal{
{domain.ErrRoleNotFound, http.StatusNotFound, apperr.RoleNotFoundErrorMessage},
{domain.ErrRoleLocked, http.StatusForbidden, apperr.RoleLockedErrorMessage},
{domain.ErrBuiltInRole, http.StatusConflict, apperr.RoleBuiltInErrorMessage},
{domain.ErrBuiltInName, http.StatusBadRequest, apperr.BuiltInNameErrorMessage},
{domain.ErrNotBuiltIn, http.StatusBadRequest, apperr.NotBuiltInErrorMessage},
{domain.ErrOwnRole, http.StatusForbidden, apperr.OwnRoleErrorMessage},
{domain.ErrRoleNameTaken, http.StatusConflict, apperr.RoleNameTakenErrorMessage},
{domain.ErrRoleConflict, http.StatusConflict, apperr.RoleConflictErrorMessage},
{domain.ErrRoleHasMembers, http.StatusConflict, apperr.RoleHasMembersErrorMessage},
{domain.ErrInvalidReassign, http.StatusBadRequest, apperr.InvalidReassignErrorMessage},
{domain.ErrInvalidRoleName, http.StatusBadRequest, apperr.InvalidRoleNameErrorMessage},
{domain.ErrUnknownPermission, http.StatusBadRequest, apperr.UnknownPermissionErrorMessage},
{domain.ErrInvalidScope, http.StatusBadRequest, apperr.InvalidScopeErrorMessage},
{domain.ErrOwnerOnlyGrant, http.StatusBadRequest, apperr.OwnerOnlyPermissionErrorMessage},
{domain.ErrDuplicateGrant, http.StatusBadRequest, apperr.DuplicatePermissionErrorMessage},
{domain.ErrUnknownContentType, http.StatusBadRequest, apperr.UnknownContentTypeErrorMessage},
}
func writeRoleError(w http.ResponseWriter, err error) {
if access.WriteIfMissingPermission(w, err) {
return
}
var escalation domain.EscalationError
if errors.As(err, &escalation) {
w.Header().Set(apperr.EscalationHeader, escalation.Key)
http.Error(w, apperr.EscalationErrorMessage, http.StatusForbidden)
return
}
for _, candidate := range refusals {
if errors.Is(err, candidate.cause) {
http.Error(w, candidate.message, candidate.status)
return
}
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
func (h *Handler) List(w http.ResponseWriter, _ *http.Request) {
listed, err := h.ListUseCase.List()
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, listed)
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
found, err := h.GetUseCase.Get(id)
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, found)
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var request PostRole
if !decodeRequest(w, r, &request) {
return
}
created, err := h.CreateUseCase.Create(r.Context(), useCases.CreateRoleCommand{
Caller: access.CallerOf(r), Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants),
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusCreated, created)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request PutRole
if !decodeRequest(w, r, &request) {
return
}
updated, err := h.UpdateUseCase.Update(r.Context(), useCases.UpdateRoleCommand{
Caller: access.CallerOf(r), ID: id, Name: request.Name, Description: request.Description, Grants: toGrants(request.Grants), Version: request.Version,
})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, updated)
}
func (h *Handler) Reset(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ResetRole
if !decodeRequest(w, r, &request) {
return
}
restored, err := h.UpdateUseCase.Reset(r.Context(), useCases.ResetRoleCommand{Caller: access.CallerOf(r), ID: id, Version: request.Version})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, restored)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
if err := h.DeleteUseCase.Delete(r.Context(), useCases.DeleteRoleCommand{Caller: access.CallerOf(r), ID: id}); err != nil {
writeRoleError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Reassign(w http.ResponseWriter, r *http.Request) {
id, ok := readId(w, r)
if !ok {
return
}
var request ReassignRole
if !decodeRequest(w, r, &request) {
return
}
moved, err := h.ReassignUseCase.Reassign(r.Context(), useCases.ReassignRoleMembersCommand{Caller: access.CallerOf(r), SourceID: id, TargetID: request.TargetRoleID})
if err != nil {
writeRoleError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]int{"moved": moved})
}
+18
View File
@@ -100,3 +100,21 @@ const InvalidFieldErrorMessage = "invalid_field"
const SchemaIncompatibleErrorMessage = "schema_incompatible"
const EntryNotFoundErrorMessage = "entry_not_found"
const InvalidEntryErrorMessage = "invalid_entry"
const RoleNotFoundErrorMessage = "role_not_found"
const RoleLockedErrorMessage = "role_locked"
const RoleBuiltInErrorMessage = "role_built_in"
const BuiltInNameErrorMessage = "built_in_name"
const NotBuiltInErrorMessage = "not_built_in"
const OwnRoleErrorMessage = "own_role"
const EscalationErrorMessage = "escalation"
const EscalationHeader = "X-Escalation-Key"
const RoleNameTakenErrorMessage = "role_name_taken"
const RoleConflictErrorMessage = "role_conflict"
const RoleHasMembersErrorMessage = "role_has_members"
const InvalidRoleNameErrorMessage = "invalid_role_name"
const UnknownPermissionErrorMessage = "unknown_permission"
const InvalidScopeErrorMessage = "invalid_scope"
const OwnerOnlyPermissionErrorMessage = "owner_only_permission"
const DuplicatePermissionErrorMessage = "duplicate_permission"
const UnknownContentTypeErrorMessage = "unknown_content_type"
+35
View File
@@ -14,6 +14,7 @@ import (
"RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/permission"
rolehandler "RenewCMS/api/handlers/role"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/site"
"RenewCMS/api/handlers/staticimages"
@@ -244,6 +245,39 @@ func NewSiteRouter(container *UseCases, trustedProxies []string) http.Handler {
return r
}
func NewRoleRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
roleHandler := rolehandler.NewHandler(container.ListRolesUseCase, container.GetRoleUseCase, container.CreateRoleUseCase, container.UpdateRoleUseCase, container.DeleteRoleUseCase, container.ReassignRoleMembersUseCase)
r.Group(func(r chi.Router) {
r.Use(authtoken.Verifier(container.Token))
r.Use(container.apiKeyAuth())
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesView))
r.Get("/", roleHandler.List)
r.Get("/{id}", roleHandler.Get)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireSession)
r.Use(authtoken.Verifier(container.Token))
r.Use(middleware.AuditContext(container.GetUserUseCase, trustedProxies))
r.Use(middleware.RequireSignedIn)
r.Use(authtoken.Authenticator)
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Use(container.require(role.KeyRolesManage))
r.Post("/", roleHandler.Create)
r.Put("/{id}", roleHandler.Update)
r.Delete("/{id}", roleHandler.Delete)
r.Post("/{id}/reassign", roleHandler.Reassign)
r.Post("/{id}/reset", roleHandler.Reset)
})
return r
}
func NewAuthorRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
@@ -474,6 +508,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container, settings.TrustedProxies))
r.Mount("/site", NewSiteRouter(container, settings.TrustedProxies))
r.Mount("/role", NewRoleRouter(container, settings.TrustedProxies))
r.Mount("/author", NewAuthorRouter(container, settings.TrustedProxies))
r.Mount("/content-type", NewContentTypeRouter(container, settings.TrustedProxies))
r.Mount("/builtin-type", NewBuiltinTypeRouter(container, settings.TrustedProxies))
+52 -1
View File
@@ -50,7 +50,7 @@ key never reads the audit trail. Events are append-only; there is no `POST`, `PU
| `GET /v1/activity/export` | Owner, admin | The same filters as the list without `page` or `size`, as a `text/csv; charset=utf-8` attachment (`when,actor,action,target,outcome,source,ip`). Cells starting with `=`, `+`, `-` or `@` get a leading quote. |
Query: `q`, `actor` (`user:ID`, `key:ID`, `system`), `category` (`articles`, `media`, `users`,
`api_keys`, `auth`, `system`, `sites`, `authors`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
`api_keys`, `auth`, `system`, `sites`, `authors`, `roles`), `outcome` (`all`, `success`, `failed`), `security` (`true`), `range`
(`today`, `7d`, `30d`, `all`, `custom`; default `7d`), `from` and `to` (dates, with `custom`), `page` (from
1) and `size` (25, 50 or 100). Any invalid value answers 400 with the body-mismatch message. Dates
are UTC. The list answers `{ items, total, page, size }`, newest first (`created_at`, then `id`).
@@ -353,6 +353,57 @@ disabled variant on `POST`), 413 "The source image is too large to generate this
source image can't be read, so this variant can't be generated.", 422 "Variants aren't generated for
this format." and 500 "Internal server error".
## Roles
Routes under `/v1/role`, behind the verified chain. Reads accept a key; every write is session only (a key answers
403 "This action needs a real session."). The permission is checked before the id, the body and any lookup.
| Route | Permission | Purpose |
| --- | --- | --- |
| `GET /v1/role` | `roles.view` | Every role by id, built-ins first. |
| `GET /v1/role/{id}` | `roles.view` | One role. |
| `POST /v1/role` | `roles.manage` | Create a role (201). `built_in` and `locked` in the body are ignored. |
| `PUT /v1/role/{id}` | `roles.manage` | Replace name, description and grants. `version` is required and must match. |
| `DELETE /v1/role/{id}` | `roles.manage` | Delete a role with no members (204). |
| `POST /v1/role/{id}/reassign` | `roles.manage` | Move every member to `{"target_role_id": N}`; answers `{"moved": n}`. |
| `POST /v1/role/{id}/reset` | `roles.manage` and `roles.edit_owner_only` | Restore a built-in role to its seeded grants and description; body `{"version": N}`. |
A role is `{ id, name, description, built_in, locked, version, grants, members, updated_at, updated_by }`.
`grants` is the normalised effective list: catalogue order, unknown keys and owner-only keys on an unlocked role
dropped, duplicates resolved to the most restrictive scope, and a per-type entry (`content.update:events`, scope
`none` included) right after its wildcard. A response is always valid as the `grants` of a `PUT`. `members` counts
users by their effective role (the legacy `owner` and `admin` strings count for the built-in role of that name).
`updated_at` (RFC 3339, UTC) and `updated_by` (`{ id, name }`, the display name else the username) are `null` for a role
never edited since seeding, and `updated_by` is also `null` once the saving user is deleted.
Refusals are plain text, one snake_case code per body:
| Status | Body | When |
| --- | --- | --- |
| 400 | `unknown_permission`, `invalid_scope`, `owner_only_permission`, `duplicate_permission`, `unknown_content_type` | A grant is not valid (`unknown_content_type` for a `content.*:{type}` key that is neither `article` nor a content type). |
| 400 | `invalid_role_name`, `built_in_name`, `not_built_in`, `invalid_reassign` | Blank name after trim; renaming a built-in role; resetting a custom role; a bad reassign target (self, the Owner role, unknown). |
| 403 | `role_locked` | Editing, deleting or moving from the Owner role. |
| 403 | `missing_permission` | The usual gate, plus `X-Missing-Permission: roles.edit_owner_only` when a built-in role is edited or reset without that permission. |
| 403 | `own_role` | Editing, deleting or moving members out of the caller's own role. |
| 403 | `escalation` | The caller would grant, or touch a role holding, more than they hold. `X-Escalation-Key` names the first offending key, such as `content.publish` or `content.publish:events`. |
| 404 | `role_not_found` | Unknown role id. |
| 409 | `role_name_taken`, `role_conflict`, `role_has_members`, `role_built_in` | Name already used (case-insensitive); stale `version`; the role still has members; deleting or moving members out of a built-in role. |
Names are trimmed and unique case-insensitively. The order of checks on a write is: permission, id, body, role lookup,
locked, built-in rules, own role, existing role covered by the caller, version, grant validation, new grants covered
by the caller, name taken, write. Only the Owner holds `roles.edit_owner_only`, so only the Owner edits the Admin and
Writer roles; owner-only keys are refused on every role a client can write. A save with nothing changed answers 200
at the same version and records nothing. Members see an edit on their next request, and so do the keys they created.
Activity: `role.create`, `role.edit`, `role.delete` and `role.reassign` in the `roles` category, target
`{ type: "role", id, name }`. `role.edit` carries `name` and `description` changes and the params `added`, `removed`,
`changed` (`{ key, from, to }`) and `members`; `role.reassign` carries `from`, `to` and `moved` and is recorded only when
`moved` is above 0. A refusal records nothing.
`GET /v1/auth/me` lists per-type entries in `access.permissions` (scope `none` included), right after their wildcard.
Deleting a content type removes its per-type rows from every role (the affected roles' `version` goes up by one), and
creating one with the key of a deleted type clears any stale row for that key.
## Content permissions
Articles and the entries of custom content types are checked against the `content.*` permissions of the
+2 -2
View File
@@ -113,9 +113,9 @@ At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMe
### Roles and permissions
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository.Save` is version checked: it updates the row only when the caller's version matches, bumps the version, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`.
`internal/domain/role` holds the permission catalogue, the built-in roles, one exported constant per catalogue key (`roleKeys.go`, the only place a key is spelled in `api/`) and the pure rules over grants: `ValidateGrants`, `ParseTypedKey` and `Role.GrantSet()`. `Role.GrantSet()` is the single constructor of a `GrantSet` and the only place a stored grant becomes an effective one: it drops unknown keys, typed keys on non-content permissions or with an invalid type key, and owner-only permissions on a role that is not locked; it treats a toggle stored at `own` as `none`; and it resolves duplicate rows to the most restrictive scope. `ValidateGrants` rejects the same rows at write time (`ErrDuplicateGrant`, `ErrOwnerOnlyGrant`, `ErrInvalidScope`, `ErrUnknownPermission`), so a bad row is refused when written and ignored when read. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the typed row replaces the wildcard for that type and a `none` row denies. A `GrantSet` is opaque: `ScopeFor(key, typeKey)` resolves wildcard and typed rows, `Permits(key, typeKey, callerID, createdBy)` is the one implementation of own and all (`created_by` 0 and caller 0 own nothing), and `Holds`, `HoldsFor` and `HoldsAll` answer the gate questions. Use cases, handlers and middleware never compare a caller id with `CreatedBy` and never read grant rows. `role.Repository` holds `List` (with every role's grants, in one pass), `Get`, `GetByName`, `Create`, `Save`, `Delete`, `ReassignMembers` and `RemoveTypedGrants`; there is no separate grant listing. `Save` is version checked: it updates the row only when the caller's version matches, bumps the version, writes `updated_at` and `updated_by`, replaces the grants in the same transaction and returns `ErrRoleConflict` otherwise. It never changes `built_in` or `locked`. `Delete` removes the role and its grants in one transaction and returns `ErrRoleHasMembers` when a user still has the role id; `ReassignMembers(from, to)` changes only `role_id` and returns the count; `RemoveTypedGrants(typeKey)` removes the five `content.*:{typeKey}` rows from every role and bumps the version of each affected role once. The role domain also owns `GrantSet.Covers(other, typeKeys)`, the single implementation of "the caller holds at least these grants" behind the no-escalation rule (it returns an `EscalationError` naming the first offending key), `Role.EffectiveGrants()` (the normalised list used by role responses, the audit diff and `/v1/auth/me`) and `DiffGrants`. The roles API (`/v1/role`) is served by `ListRolesUseCase`, `GetRoleUseCase`, `CreateRoleUseCase`, `UpdateRoleUseCase` (which also resets a built-in role), `DeleteRoleUseCase` and `ReassignRoleMembersUseCase`; they depend on `role.Repository`, `user.Repository`, `contenttype.Repository` and `audit.Recorder` only, and `api/handlers/role` maps their domain errors to the status and code. `DeleteContentTypeUseCase` and `CreateContentTypeUseCase` also take the role repository to clear per-type grants. `DeleteContentTypeUseCase` deletes the type and then clears its typed rows; if that cleanup fails the rows stay, which is safe because the role views (`GET /v1/role`, `GET /v1/role/{id}` and every write response) drop typed rows for content types that no longer exist, so a GET body stays a valid PUT body, and `CreateContentTypeUseCase` clears the rows of a key right after a successful create, before any role can match the new type. A failed create never touches the roles. The repository re-checks the name case-insensitively inside the `Create` and `Save` transactions (`ErrRoleNameTaken`), locks the role row in `Delete` before counting members (a no-op on SQLite) and guards the `ReassignMembers` update with an `EXISTS` on the target role, answering `ErrInvalidReassign` when the target vanished.
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (or `GetByName` for the legacy owner and admin strings), builds the `GrantSet` from the grants the role already carries and never calls `ListGrants`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
Enforcement path. The first middleware that needs the signed-in user (`AuditContext`, `RequireCurrentToken`, `RequireVerifiedUser` or a permission gate) stores a request-scoped holder in the context (`access.WithCurrentUser`), so the user is loaded once per request; an API key adds the one load of its own authentication. `CheckPermissionUseCase` resolves the caller's role with one `role.Repository.Get` (or `GetByName` for the legacy owner and admin strings), builds the `GrantSet` from the grants the role already carries and never calls `List`. `RequireTypedPermission` gates a content route on the typed scope before any lookup (the type key is `article` for articles and the `{key}` of the URL for entries) and puts the caller (`access.Caller`: user id and grants) in the context. The optional `OptionalAccess` middleware does the same for the public article routes without ever refusing: no cookie, an unverified user, a stale token or an unresolvable role leave the caller anonymous, and only a role repository failure answers 500. The handler passes the caller to the use case as `useCases.ContentCaller`; the use case loads the article or entry, decides the scope with `Permits` right after the load and before any other lookup or validation, and returns a `role.MissingPermissionError`, which `access.WriteIfMissingPermission` writes as 403 with the base key in `X-Missing-Permission`. Reading follows the same helper: an article is visible when it is online or `content.read` permits its `created_by`, and an entry the caller may not read is `entry.ErrEntryNotFound`. A list under scope `own` is filtered before totals, search and paging. `CreateContentTypeUseCase` refuses the built-in type keys with `contenttype.ErrKeyTaken`.
Built-in reconciliation: `database.SeedBuiltInRoles` creates the three built-in roles when they are missing and never overwrites an edited one. On every start it adds the catalogue keys a built-in role lacks: always for Owner, at scope `all`, and for Admin and Writer only while their version is still 1. It never removes or downgrades a grant and never bumps the version.
+1
View File
@@ -51,6 +51,7 @@ Go only, no SPA. Package `test` (black-box). New files: `test/activity_producers
34. Secret sweep: after exercising every producer, marshal every stored event, the `/v1/activity` response and the `/v1/activity/export` file; assert none contains the password, hash, verification code, invite code, raw API key, article body, file bytes, asset URL or staging path. The log buffer is free of them too.
35. Allowlist: every `Change.Field` is in `{title, online, body, filename, display_name}` and every kind is `text` or `body`.
36. Read-back: list by `category=articles|media|users|system` and `outcome=failed`; compare action sets with the feature tables.
Superseded in part by roles_permissions_roles_api: the closed category list also holds `roles` (target `role`) and the actions are `role.create`, `role.edit`, `role.delete` and `role.reassign`, covered in test/roles_api_*_test.go.
## G. Mutation guard (gremlins stays clean)
37. `task mutate` scope is `./internal/infrastructure/useCases`. Each new branch needs a killing assertion: every no-op guard (body, online, filename, display name) with equal and differing values; delta sign and rune-versus-byte count; success versus failed outcome and recorded once after the change (event count and order against the repository call); every `errors.Is` row of the reason map plus the generic fallback; every `ctx` forwarding; the system-actor defaults. Bare-bool conditions produce no mutant, so cover both branches with tests regardless.
@@ -152,7 +152,7 @@ Feature: Roles and permissions content scope: own and all enforcement on article
Given a role repository that counts calls and a user repository that counts calls
When a gated request is sent with a session cookie
Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once
And ListGrants is called zero times by the permission check and by GetMyAccessUseCase
And List is called zero times by the permission check and by GetMyAccessUseCase (ListGrants was removed by roles_permissions_roles_api)
And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load)
And the status codes and the order of checks of the enforcement feature are unchanged
And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it
@@ -456,9 +456,9 @@ Feature: Roles and permissions content scope: own and all enforcement on article
Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all
When the client sends GET /v1/auth/me
Then access.permissions holds content.update at own and no settings.delete_site
And typed grants are not listed in this slice
And typed grants are not listed in this slice (superseded by roles_permissions_roles_api: typed entries, scope none included, are listed)
Scenario: Out of scope here
Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route
And typed grants are written by slice 3 and exposed to the SPA by a later slice
And rows of deleted content types in typed grants are cleaned up by slice 3
And typed grants are written by slice 3 and exposed to the SPA by a later slice (done by roles_permissions_roles_api: the API writes them and /auth/me lists them)
And rows of deleted content types in typed grants are cleaned up by slice 3 (done by roles_permissions_roles_api)
@@ -6,9 +6,9 @@ Do not copy any `Role.Allows`, `Role ==` or hand-rolled own/all comparison into
## Deferred to later slices
- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types.
- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types. Done by roles_permissions_roles_api.
- 4: role change, invite with role, ownership transfer, removal of the legacy role column.
- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants.
- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants (the API side is done by roles_permissions_roles_api).
- Not changed: GET /v1/media/{id}/usage still lists articles and entries for any media.view holder. media.delete own fails closed. Entry relation summaries are not gated by the related type.
## Design notes for the user
@@ -44,7 +44,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
## Test infrastructure first
1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `ListGrants`. Its `Get` returns the role with `Grants` filled, like the persistence repository.
1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `List` (ListGrants was removed by roles_permissions_roles_api). Its `Get` returns the role with `Grants` filled, like the persistence repository.
2. User fake with a `Get` call counter (existing `mockUserRepo` plus a counter, or a wrapper). Users 3, 5, 9, 12 (unverified Admin), 13 (role_id 99), 20 (hand-placed owner-only), 21-27.
3. Article fake and entry fake seeded with the ids, states and created_by values of the Background. Both record writes, so "article unchanged", "entry stored" and "nothing stored" are asserted on the store. A recording `audit.Recorder` to assert "no audit event".
4. HTTP harness: `validator.InitValidator()`. Build `NewArticleRouter` and `NewContentTypeRouter` equivalents mounted on a fresh `chi.NewRouter()` mirroring `api/router.go`: `authtoken.Verifier`, `container.apiKeyAuth()` equivalent, the new optional-access middleware on the public article group, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, the typed permission gate, and the handlers over real use cases wired to the fakes. No live server, no real SQLite, no real SMTP.
@@ -68,7 +68,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
## B. Use cases (fakes only)
13. `CheckPermission` (typed variant) table: Owner, Admin, Writer, each custom role, an unresolvable role (`role.ErrMissingPermission` via `errors.Is`, key via `errors.As`), a failing repository (non-sentinel error), and a live grant change between two calls.
14. Counters: for one `Require` call, `ListGrants` is 0, `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`.
14. Counters: for one `Require` call, `List` is 0 (was `ListGrants`), `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`.
15. Article use cases (get, list, update, update-is-online, delete, create, SEO check): table of caller grants × article × expected error or result, per the feature Outlines. Assert for refusals: error is `role.MissingPermissionError{Key: "content.update"}` and so on, the store is unchanged, the recorder has no event, and no site/author/image lookup happened (counters on those fakes).
16. Entry use cases (list, get, create, update, delete): same, including `created_by` unchanged on update, own-filtered list totals and `?q=` over the own set, and `entry.ErrEntryNotFound` for a non-owned read under own.
17. Order: scope check after the load and before validation: PUT with an invalid body for a non-owned entry gives the scope error, not `FieldError`.
@@ -86,7 +86,7 @@ Routes that no key gates after 2b among the content routes: none. Entry routes n
26. Live role: edit grants and `role_id` between requests with the same cookie and the same API key.
27. API key: article created through the key has `created_by` of the creator.
28. Order: unverified 403, stale token 401, no cookie 401 before the permission gate on the authed article and entry routes.
29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `ListGrants`; with an API key at most 2 user `Get`. Run on one article route and one entry route.
29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `List` (was `ListGrants`); with an API key at most 2 user `Get`. Run on one article route and one entry route.
30. Owner-only: user 20 with a hand-placed `system.apply_updates=all` gets 403 on `POST /v1/system/update/check`; `/auth/me` omits it.
31. Media rows of the Outline and the "usage not filtered" assertion.
32. Regression: run the 2a Outline "Existing owner and admin behaviour does not regress", the authors.edit Outline and the activity/api-key scope Outlines unchanged.
@@ -116,5 +116,5 @@ Add a "superseded by roles_permissions_content_scope" note or edit in place:
- Start `task dev:api` on a copy of a migrated database. As the Writer: publish returns 403, saving a draft works and stays a draft. As the Admin: publish works.
- With two accounts on a custom role holding content.update=own, confirm account B gets 403 on A's article and entry, and A can edit them.
- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. This is the only way to try typed grants until slice 3.
- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. Typed grants can now also be written through PUT /v1/role/{id} (roles_permissions_roles_api).
- Confirm no password or secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).
@@ -12,7 +12,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
- Scope with values none, own and all
- Role {ID uint32, Name, Description, BuiltIn bool, Locked bool, Version uint32, Grants}
- the sentinels ErrRoleNotFound and ErrUnknownPermission
- the Repository interface (list, get by id, get by name, list grants, create, save)
- the Repository interface (list, get by id, get by name, create, save; list grants was removed and delete, reassign members and remove typed grants added by roles_permissions_roles_api)
- the pure function Role.Allows(key, owned bool) bool (superseded by roles_permissions_content_scope: Role.Allows and AllowsFor are removed, a role is evaluated through Role.GrantSet(), ScopeFor and Permits)
And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only):
| group | key | kind | owner-only |
@@ -51,7 +51,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
| system | system.edit_retention | B | yes |
| system | system.transfer_ownership | B | yes |
And content permissions are stored under wildcard keys meaning "all content types"; per-type
overrides of the form content.<action>:<type_key> are deferred to slice 3 (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3)
overrides of the form content.<action>:<type_key> are written by slice 3, roles_permissions_roles_api (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3)
And a role stores one row per granted key, with scope own or all. A key with no row is none or off.
An on/off key that is on is stored as scope all.
And three built-in roles are seeded by persistence/database (like SeedDefaultSite), idempotently,
@@ -0,0 +1,381 @@
Feature: Roles and permissions roles API: list, read, create, edit, delete and reassign roles, with write-time validation, no-escalation and activity
As the owner of a RenewCMS site
I want to manage roles over /v1/role with the checks the design asks for
So that an admin can build a Contributor role in under a minute, nobody can grant what they do not hold,
and every change is audited
Background:
Given the spec of record is docs/design/screens/roles.md (sections 1, 2, 4, 7, 8, tables "No-escalation feedback" and "HTTP status to copy") and docs/design/routes.md "Roles and permissions"
And features/roles_permissions_foundation.feature provides the catalogue, the built-in roles and /auth/me,
features/roles_permissions_enforcement.feature provides the 403 contract (body "missing_permission\n", header X-Missing-Permission with the base key),
and features/roles_permissions_content_scope.feature provides GrantSet, typed keys and ValidateGrants
And "403" means status 403, body "missing_permission\n" and the header X-Missing-Permission, nothing stored, no audit event
And every other refusal is plain text from http.Error, one snake_case code per line below. Success bodies are JSON, with snake_case fields
And the codes are:
| situation | status | body | extra header |
| role id unknown | 404 | role_not_found | |
| edit or delete the Owner (Locked) role | 403 | role_locked | |
| edit-owner-only needed for a built-in role | 403 | missing_permission | X-Missing-Permission: roles.edit_owner_only |
| delete or reassign from a built-in role | 409 | role_built_in | |
| rename a built-in role | 400 | built_in_name | |
| edit, delete or reassign from the caller's own role | 403 | own_role | |
| grant or touch more than the caller holds | 403 | escalation | X-Escalation-Key: the offending key |
| name already used | 409 | role_name_taken | |
| version mismatch on PUT | 409 | role_conflict | |
| delete a role that still has members | 409 | role_has_members | |
| bad reassign target | 400 | invalid_reassign (existing apperr) | |
| blank name after trim | 400 | invalid_role_name | |
| unknown or malformed permission key | 400 | unknown_permission | |
| scope not valid for the key | 400 | invalid_scope | |
| owner-only key on a role that is not Locked | 400 | owner_only_permission | |
| the same key twice | 400 | duplicate_permission | |
| typed key whose type is not article or a content type | 400 | unknown_content_type | |
And an undecodable body is 400 apperr.BodyErrorMessage and a failed validate tag is 400 with the validator text, as in the site handler.
A non-numeric id is 400 apperr.IdUint32ErrorMessage
And the request structs carry validate tags: name "required,max=100", description "max=255", grants key "required,max=100", version "required,min=1"
And the routes are mounted in api/router.go under /v1/role (NewRoleRouter), behind authtoken.Verifier, container.apiKeyAuth(), Authenticator,
RequireCurrentToken and RequireVerifiedUser:
| route | key (role.Key constant) | session only |
| GET /v1/role | roles.view | no |
| GET /v1/role/{id} | roles.view | no |
| POST /v1/role | roles.manage | yes |
| PUT /v1/role/{id} | roles.manage | yes |
| DELETE /v1/role/{id} | roles.manage | yes |
| POST /v1/role/{id}/reassign | roles.manage | yes |
| POST /v1/role/{id}/reset | roles.manage | yes |
And the permission gate runs before the id, the body and any lookup. An API key on a write route gets 403 apperr.SessionRequiredErrorMessage
And handlers receive use cases only: ListRolesUseCase, GetRoleUseCase, CreateRoleUseCase, UpdateRoleUseCase, DeleteRoleUseCase, ReassignRoleMembersUseCase.
They depend on domain interfaces only: role.Repository, user.Repository, contenttype.Repository, audit.Recorder
And the role JSON is the domain Role (id, name, description, built_in, locked, version, grants) plus members (number), updated_at and updated_by ({id, name} or null)
And grants in every response are the normalised effective list: catalogue order, unknown keys and owner-only keys on unlocked roles dropped,
duplicates resolved to the most restrictive scope, a typed entry (content.<action>:<type_key>, scope none included) right after its wildcard in type-key order.
A response body is always valid as the grants of a PUT. One domain function produces this list, used by the role responses, the diff and /auth/me
And a user's effective role id is role_id, else the built-in named by the legacy owner/admin string. members counts users by that id
And the no-escalation comparison is one domain function GrantSet.Covers(other, typeKeys): for every catalogue key, and for each content.* key and each
type key in {article} plus the existing content types, the scope of other must not rank above the scope of the caller (none < own < all). The first violation, in catalogue order, names the key
And the order of checks on every write is: gate, id (400), body (400), load role (404), locked (403), built-in rules (403/409/400),
own role (403), existing role covered by the caller (403 escalation), version (409 role_conflict), ValidateGrants and type keys (400),
new grants covered by the caller (403 escalation), name taken (409), write
And the roles and users of the Background (all verified, valid jwt cookie, token_version 0):
| role | grants | members |
| Owner | built-in, Locked, 34 keys | user 3 |
| Admin | built-in, 29 keys | user 5, user 10 (role_id 0, legacy admin) |
| Writer | built-in, 9 keys | user 9 |
| Contributor | content.read=all, content.create, content.update=own | users 41, 42, 43, 44 |
| Intern | content.read=own | none |
| Content lead | content.read=all, content.update=all, content.publish=all, content.delete=all | user 45 |
| Role manager | roles.view, roles.manage, content.read=all, content.create, content.update=own, content.publish=own, schema.view | user 31 |
| Viewer | roles.view | user 32 |
| Typed manager | roles.view, roles.manage, content.read=all, content.update=all, content.update:article=none | user 33 |
| Narrow manager | roles.view, roles.manage, content.read:events=all | user 34 |
| Events editor | content.read=all, content.update:events=own, content.delete:events=none, content.update:venues=all | user 35 |
And user 12 is an unverified Admin, and the content types are "events" and "venues"
Scenario Outline: The gate decides who reads and who writes roles
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 3 Owner | GET /v1/role | 200 |
| user 5 Admin | GET /v1/role | 200 |
| user 32 Viewer | GET /v1/role | 200 |
| user 32 Viewer | GET /v1/role/<Writer id> | 200 |
| user 9 Writer | GET /v1/role | 403 roles.view |
| user 9 Writer | GET /v1/role/abc | 403 roles.view (before the id is read) |
| user 32 Viewer | POST /v1/role {"name": "X"} | 403 roles.manage |
| user 32 Viewer | PUT /v1/role/999 | 403 roles.manage (before any lookup) |
| user 32 Viewer | DELETE /v1/role/999 | 403 roles.manage |
| user 32 Viewer | POST /v1/role/999/reassign | 403 roles.manage |
| no cookie | GET /v1/role | 401 apperr.UnauthorizedErrorMessage |
| user 12 unverified Admin | GET /v1/role | 403 apperr.VerificationRequiredErrorMessage |
| API key created by user 5 | GET /v1/role | 200 |
| API key created by user 5 | POST /v1/role {"name": "Keyed"} | 403 apperr.SessionRequiredErrorMessage, nothing stored |
| stale token_version | GET /v1/role | 401 |
And a session follows the role live: after Viewer gains roles.manage in the repository, the same cookie's next POST passes the gate, with no re-login
Scenario: The list returns every role with grants and member counts
When user 5 sends GET /v1/role
Then the status is 200 and the body is a JSON array ordered by id, built-ins first
And Owner has members 1, Admin 2 (user 10 counted through the legacy fallback), Writer 1, Contributor 4, Intern 0
And each item holds its normalised grants, built_in, locked and version
And the Owner item holds the 34 keys at all and Admin the 29 keys at all
And the permission check and /auth/me do not call List
Scenario: Reading one role
When user 32 sends GET /v1/role/<Contributor id>
Then the status is 200, members is 4, version 1 and grants are content.read all, content.create all, content.update own
And GET /v1/role/999 is 404 role_not_found and GET /v1/role/abc is 400 apperr.IdUint32ErrorMessage
Scenario: A role with a hand-placed owner-only key or duplicate rows is returned normalised
Given a custom role with the rows content.update=own, content.update=all and a hand-placed system.apply_updates=all
When user 5 sends GET /v1/role/<that id>
Then grants hold content.update at own and no system.apply_updates
Scenario: Creating the Contributor role (design flow, section 11)
Given user 5 holds roles.manage
When user 5 sends POST /v1/role with {"name": "Writer plus", "description": "Writes posts and edits only their own", "grants": [{"key": "content.read", "scope": "all"}, {"key": "content.create", "scope": "all"}, {"key": "content.update", "scope": "own"}, {"key": "schema.view", "scope": "all"}, {"key": "media.view", "scope": "all"}]}
Then the status is 201 and the body is the role with built_in false, locked false, version 1, members 0
And the grants are in catalogue order and the new role appears in GET /v1/role
And exactly one role.create audit event is recorded (see "Activity")
And a body field built_in or locked sent by the client is ignored
Scenario: A new role may start with nothing granted
When user 5 sends POST /v1/role with {"name": "Empty"} and with {"name": "Empty 2", "grants": []}
Then both answer 201 with grants []
Scenario Outline: Write-time validation of create and edit
When user 5 sends POST /v1/role with name "Probe" and grants <grants>
Then the result is <result>
And nothing is stored and no audit event is recorded when the result is not 201
Examples:
| grants | result |
| content.update=all | 201 |
| content.update:events=own | 201 |
| content.update:article=none | 201 |
| content.update:events=all, content.update=own | 201 |
| content.explode=all | 400 unknown_permission |
| schema.view:events=all | 400 unknown_permission |
| content.update:Bad Key=all | 400 unknown_permission |
| content.update:nope=all | 400 unknown_content_type |
| content.update:author=all | 400 unknown_content_type |
| content.update=none | 400 invalid_scope |
| schema.view=own | 400 invalid_scope |
| content.update=banana | 400 invalid_scope |
| content.create:events=own | 400 invalid_scope |
| settings.delete_site=all | 400 owner_only_permission |
| system.apply_updates=all | 400 owner_only_permission |
| content.update=all, content.update=own | 400 duplicate_permission |
| content.update:events=all, content.update:events=own| 400 duplicate_permission |
And the grants are validated by role.ValidateGrants(false, grants) in the use case, then the type keys against the content types plus the built-in key "article"
And the owner-only keys are refused on every role a client can write, including a built-in Admin or Writer
Scenario Outline: Name and description rules
When user 5 sends POST /v1/role with name <name> and description <description>
Then the result is <result>
Examples:
| name | description | result |
| "" | "" | 400 (validate tag required) |
| " " | "" | 400 invalid_role_name |
| (101 characters) | "" | 400 (validate tag max=100) |
| (100 characters) | "" | 201 |
| " Reviewer " | "" | 201, name stored as "Reviewer" |
| "Reviewer 2" | (256 characters) | 400 (validate tag max=255) |
| "Writer" | "" | 409 role_name_taken |
| "WRITER" | "" | 409 role_name_taken (case-insensitive) |
| " contributor " | "" | 409 role_name_taken |
And a malformed JSON body is 400 apperr.BodyErrorMessage
Scenario Outline: No-escalation on create and edit
Given the caller edits the role Contributor (read all, create, update own) with the extra grant <grant>
When <caller> sends PUT /v1/role/<Contributor id> with the current version and the Contributor grants plus <grant>
Then the result is <result>
Examples:
| caller | grant | result |
| user 31 Role manager | content.publish=own | 200 |
| user 31 Role manager | content.publish=all | 403 escalation, X-Escalation-Key content.publish |
| user 31 Role manager | content.delete=own | 403 escalation (caller holds none), X-Escalation-Key content.delete |
| user 31 Role manager | schema.edit=all | 403 escalation |
| user 31 Role manager | content.publish:events=none | 200 (denying never escalates) |
| user 31 Role manager | content.publish:events=all | 403 escalation, X-Escalation-Key content.publish:events |
| user 31 Role manager | content.update:events=own | 200 |
| user 31 Role manager | content.update:events=all | 403 escalation |
| user 33 Typed manager | content.update=all | 403 escalation (update would become all on article), X-Escalation-Key content.update:article |
| user 33 Typed manager | content.update=all, content.update:article=own | 403 escalation (own is above the caller's none on article), X-Escalation-Key content.update:article |
| user 33 Typed manager | content.update=all, content.update:article=none | 200 |
| user 34 Narrow manager | (no change) | 403 escalation (Contributor's wildcard read is above a typed-only reader) |
| user 5 Admin | content.delete=all | 200 |
| user 3 Owner | content.delete=all | 200 |
And the same comparison applies on POST /v1/role with the same grants
And a refused call changes no field, bumps no version and records no event
Scenario Outline: A caller cannot edit, delete or move members of a role above their own grants
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 31 Role manager | PUT /v1/role/<Content lead id> with no change | 403 escalation (Content lead publishes and updates at all) |
| user 31 Role manager | DELETE /v1/role/<Content lead id> | 403 escalation |
| user 31 Role manager | POST /v1/role/<Content lead id>/reassign {"target_role_id": <Intern id>} | 403 escalation |
| user 31 Role manager | PUT /v1/role/<Contributor id> with a harmless change | 200 (Contributor is covered) |
| user 5 Admin | PUT /v1/role/<Content lead id> with a harmless change | 200 |
Scenario Outline: Editing a role
Given the roles of the Background, each at version 1
When <caller> sends PUT /v1/role/<target> with <body>
Then the result is <result>
Examples:
| caller | target | body | result |
| user 5 Admin | Contributor | name same, grants plus content.delete=own, version 1 | 200, version 2, members 4, grants include content.delete own |
| user 5 Admin | Contributor | grants minus content.update, version 1 | 200, content.update absent |
| user 5 Admin | Contributor | description changed, version 1 | 200, version 2 |
| user 5 Admin | Contributor | nothing changed, version 1 | 200, version 1, no write, no event |
| user 5 Admin | Contributor | nothing changed, version 7 | 409 role_conflict |
| user 5 Admin | Contributor | body changed, version 7 | 409 role_conflict, role unchanged |
| user 5 Admin | Contributor | no version field | 400 (validate tag required) |
| user 5 Admin | Contributor | name "writer", version 1 | 409 role_name_taken |
| user 5 Admin | Contributor | name "contributor" (case only), version 1 | 200, name stored as sent |
| user 5 Admin | Contributor | name "Intern", version 1 | 409 role_name_taken |
| user 5 Admin | Contributor | content.explode=all | 400 unknown_permission, role unchanged |
| user 5 Admin | 999 | any valid body | 404 role_not_found |
| user 3 Owner | Writer | description changed, version 1 | 200, version 2, the role keeps built_in true |
| user 3 Owner | Writer | grants plus media.delete=own, version 1 | 200 |
| user 3 Owner | Writer | name "Author", version 1 | 400 built_in_name |
| user 3 Owner | Admin | grants minus users.remove, version 1 | 200 |
| user 3 Owner | Admin | grants plus system.apply_updates=all | 400 owner_only_permission |
| user 5 Admin | Writer | description changed, version 1 | 403 missing_permission, X-Missing-Permission roles.edit_owner_only |
| user 5 Admin | Admin | description changed, version 1 | 403 missing_permission, X-Missing-Permission roles.edit_owner_only (own role, built-in rule first) |
| user 3 Owner | Owner | any body | 403 role_locked |
| user 5 Admin | Owner | any body | 403 role_locked |
| user 31 Role manager | Role manager | description changed, version 1 | 403 own_role |
| user 5 Admin | Contributor | an Admin may edit a role they do not hold | 200 |
And a successful edit leaves the id, built_in and locked of the role unchanged
And members who hold the edited role see the new grants on their next request, with no re-login, and so do the API keys they created
And a stored built-in edit survives the next seed: SeedBuiltInRoles never overwrites a role whose version is not 1
Scenario: Who saved last
Given user 5 saved Contributor at 11:02
When user 32 sends GET /v1/role/<Contributor id>
Then updated_at is that time (RFC 3339, UTC) and updated_by is {"id": 5, "name": the display name of user 5, else the username}
And a role never edited since seeding has updated_by null
And when the saving user was deleted updated_by is null
Scenario: A stale editor gets a conflict, then reads the new state
Given user 5 and user 3 both loaded Contributor at version 1
When user 3 saves first and user 5 then saves the same version
Then user 3 gets 200 with version 2 and user 5 gets 409 role_conflict
And the stored role is user 3's
And the repository Save is a compare-and-swap on version: two concurrent saves of version 1 give exactly one 200 and one 409
Scenario Outline: Deleting a role
When <caller> sends DELETE /v1/role/<target>
Then the result is <result>
Examples:
| caller | target | result |
| user 3 Owner | Intern | 204, the role and its grants are gone, GET answers 404 role_not_found |
| user 5 Admin | Intern | 204 |
| user 5 Admin | Contributor | 409 role_has_members, nothing deleted |
| user 3 Owner | Writer | 409 role_built_in |
| user 3 Owner | Admin | 409 role_built_in |
| user 3 Owner | Owner | 403 role_locked |
| user 5 Admin | Owner | 403 role_locked |
| user 5 Admin | 999 | 404 role_not_found |
| user 31 Role manager | Role manager | 403 own_role |
| user 31 Role manager | Content lead | 403 escalation |
| user 31 Role manager | Intern | 204 |
And the Intern row of user 31 is 204, because Role manager holds content.read at all, which covers read own
And a delete never touches content: articles and entries created by the role's members keep their created_by
And the repository Delete re-checks members inside its transaction and returns role.ErrRoleHasMembers when a member appeared meanwhile, so the race answers 409 role_has_members
Scenario Outline: Moving members, then deleting
Given Contributor has members 41, 42, 43 and 44
When <caller> sends POST /v1/role/<source>/reassign with <body>
Then the result is <result>
Examples:
| caller | source | body | result |
| user 5 Admin | Contributor | {"target_role_id": <Writer id>} | 200 {"moved": 4}, users 41-44 now have the Writer role_id, tokens and cookies stay valid |
| user 5 Admin | Intern | {"target_role_id": <Writer id>} | 200 {"moved": 0}, no event |
| user 31 Role manager | Contributor | {"target_role_id": <Admin id>} | 403 escalation (Admin is above Role manager), nobody moved |
| user 31 Role manager | Contributor | {"target_role_id": <Intern id>} | 200 {"moved": 4} |
| user 5 Admin | Contributor | {"target_role_id": <Owner id>} | 400 invalid_reassign (there is one owner) |
| user 5 Admin | Contributor | {"target_role_id": <Contributor id>} | 400 invalid_reassign |
| user 5 Admin | Contributor | {"target_role_id": 999} | 400 invalid_reassign |
| user 5 Admin | Contributor | {} | 400 (validate tag required) |
| user 5 Admin | Writer | {"target_role_id": <Intern id>} | 409 role_built_in |
| user 5 Admin | Owner | {"target_role_id": <Intern id>} | 403 role_locked |
| user 5 Admin | 999 | {"target_role_id": <Writer id>} | 404 role_not_found |
| user 31 Role manager | Role manager | {"target_role_id": <Intern id>} | 403 own_role |
And after the first row, DELETE /v1/role/<Contributor id> by user 5 answers 204 and the role is gone
And the move is one statement: when the repository fails, nobody is moved and the answer is 500 "Internal server error" with a failed role.reassign event
And moving members changes only role_id. Their passwords, token_version, verification and legacy role string are untouched
Scenario Outline: Resetting a built-in role to its defaults
When <caller> sends POST /v1/role/<role>/reset with {"version": <version>}
Then the answer is <result>
Examples:
| caller | role | version | result |
| Owner | Writer, edited | current | 200 with the seeded Writer grants and description, version bumped by one, name and flags unchanged |
| Owner | Admin, edited | current | 200 with the seeded Admin grants, version bumped by one |
| Owner | Writer, pristine | current | 200, grants unchanged, no version bump, no event |
| Owner | Writer, edited | stale | 409 role_conflict |
| Owner | Owner | current | 403 role_locked |
| Owner | Contributor | current | 400 not_built_in |
| Owner | 999 | current | 404 role_not_found |
| user 5 Admin | Writer, edited | current | 403 missing_permission, X-Missing-Permission roles.edit_owner_only |
| user 32 Viewer | Writer, edited | current | 403 missing_permission, X-Missing-Permission roles.manage |
| API key of the Owner | Writer, edited | current | 403 session_required |
And the reset replaces the stored grants with BuiltInRoles() in one transaction, so the role is never seen half reset
And members of the reset role see the seeded grants on their next request, with no re-login
And a successful reset records a role.edit event with the role as target, and a refused one records nothing
Scenario: Activity events for roles
Given the audit recorder is enabled and user 5 is the actor
When user 5 creates "Reviewer", then edits it (adds content.delete=own, removes media.view, changes content.update from own to all, renames to "Reviewers"), then deletes it
Then three events are recorded in order, with category roles, outcome success, the actor user 5 and the target {"type": "role", "id": the role id, "name": the role name}
And role.create has params added = every granted entry
And role.edit has changes for name and description (text before and after) and params added [{key, scope}], removed [{key, scope}], changed [{key, from, to}] over the normalised grants,
plus members. The diff is computed on (key, scope) entries with an absent entry meaning no entry: absent to present is added, present to absent is removed, a different scope is changed
And role.delete has params members 0
And the example edit above gives added content.delete own, removed media.view all, changed content.update own to all
And the move of members records one role.reassign event with params from (role id), to (role id) and moved, only when moved is greater than 0
And a refusal (403, 404, 409, 400) before the write records nothing
And a storage failure at the write records the same event with outcome failed and a failure reason, through recordFailure like the other use cases
And GET /v1/activity?category=roles is a valid filter, visible to a caller whose activity.view is all
And audit.Categories() gains "roles", audit.TargetRole is "role", and the actions are role.create, role.edit, role.delete, role.reassign
Scenario: /auth/me exposes typed grants
Given user 35 is on the Events editor role
When user 35 sends GET /v1/auth/me
Then access.permissions is, in order: {"key": "content.read", "scope": "all"}, {"key": "content.update:events", "scope": "own"}, {"key": "content.update:venues", "scope": "all"}, {"key": "content.delete:events", "scope": "none"}
And wildcard entries with scope none are still not listed
And owner-only keys on an unlocked role and duplicate rows are normalised as before
And the roles-content-scope scenario "typed grants are not listed in this slice" is superseded by this one
Scenario: Deleting a content type removes its typed grants
Given the Events editor role at version 1 and the content type "events" has no entries and is not referenced
When user 5 sends DELETE /v1/content-type/events
Then the status is 204
And Events editor no longer holds content.update:events or content.delete:events, still holds content.read all and content.update:venues all, and has version 2
And a role that held no typed row for "events" keeps its version
And user 35 loses the events override on the next request: content.update on events falls back to the wildcard (none)
Scenario: A refused content type delete keeps the typed grants
Given the content type "events" still has entries
When user 5 sends DELETE /v1/content-type/events
Then the status is 409 apperr.ContentTypeHasEntriesErrorMessage and the Events editor role is unchanged, version 1
Scenario: A content type created with the key of a deleted one starts clean
Given a stale row content.update:events=none is stored on a custom role although no content type "events" exists
When user 5 sends POST /v1/content-type with the key "events" and a valid body
Then the status is 201 and that row is removed, so the new type is governed by the wildcard
Scenario: Content type keys do not change
Then PUT /v1/content-type/{key} never writes the key, so no typed row needs renaming
And a PUT with a different key in the body leaves the stored key unchanged, as before
Scenario: ListGrants is removed
Then role.Repository no longer has ListGrants
And role.Repository holds List (with grants), Get, GetByName, Create, Save (compare-and-swap on version, also writing updated_at and updated_by),
Delete(id) (one transaction, role.ErrRoleHasMembers when a user still has the role_id, removes the role_permission rows),
ReassignMembers(from, to) (int, error) and RemoveTypedGrants(typeKey) (int, error)
And the persistence repository asserts var _ role.Repository = &RoleRepository{} and keeps the SQL portable across SQLite, MySQL/MariaDB and PostgreSQL
Scenario: The comparison logic lives once, in the domain
Then GrantSet.Covers is the only implementation of "does the caller hold at least these grants"
And no use case, handler or middleware compares scopes by rank or reads a GrantSet entry
And api/router.go uses role.KeyRolesView, role.KeyRolesManage and no string literal equal to a catalogue key
And no handler under api/handlers/role imports persistence, mailer, models or gorm
Scenario: Out of scope here
Then there is no PUT /v1/user/{id}/role, no invite with a role, no ownership transfer and no removal of the legacy users.role string (slice 4)
And there is no SPA change (slices 5-7)
And the pending-invite count in members, and role_has_members for invites, arrive with the invite role in slice 4
And TestRouterRoutesAreInTheCatalogue was already failing and is reported, not fixed (the new routes are only added to its exemption list)
+110
View File
@@ -0,0 +1,110 @@
# QA procedure: roles_permissions_roles_api
Package: `test` (black-box). New fakes need unique names, for example `rolesAPIRoleRepo`, `rolesAPIUserRepo`, `rolesAPITypeRepo`, `rolesAPIRecorder`.
Reuse `mintCookie`, `assertGate`, the API key minting helper and the seeded fakes of `test/role_support_test.go` and `test/roles_content_scope_world_test.go` where they fit.
Do not copy any scope-rank comparison into tests: call `GrantSet.Covers`, `ScopeFor` and the exported helpers.
## Deferred to later slices
- 4: PUT /v1/user/{id}/role, invites with a role (and counting pending invites as members), ownership transfer, removal of the legacy role string.
- 5-7: SPA (roles list, editor, preview, delete dialog, role-aware shell). The SPA reacts to status, body code and the X-Missing-Permission / X-Escalation-Key headers.
## Design notes for the user
- docs/design says the conflict copy names who saved and when: this slice adds updated_at and updated_by to the role (question 1). The 409 body stays plain text.
- docs/design names the events role.created / updated / deleted. The code convention is create / edit / delete (question 8). The SPA activity copy keys on the action, so the design board needs the code names.
- docs/design has no code for "built-in role can't be deleted", "own role" and the escalation key: role_built_in, own_role, X-Escalation-Key are new.
- The design asks the Owner role to be uneditable: role_locked is checked first, before own_role and the built-in rule.
- Agents never edit docs/design: these four items are for you to relay to Claude Design.
## Inventory (route, key, session only, checks after the load, Owner / Admin / Writer)
| Route | Key | Session only | After the load | Owner / Admin / Writer |
|---|---|---|---|---|
| GET /v1/role | roles.view | no | none | ok / ok / 403 |
| GET /v1/role/{id} | roles.view | no | 404 role_not_found | ok / ok / 403 |
| POST /v1/role | roles.manage | yes | ValidateGrants, type keys, covers, name taken | 201 / 201 / 403 |
| PUT /v1/role/{id} | roles.manage | yes | locked, built-in rule, own role, covers existing, version, validate, covers new, name taken | ok / ok (not built-in) / 403 |
| POST /v1/role/{id}/reset | roles.manage and roles.edit_owner_only | yes | locked, built-in only, version, restore BuiltInRoles() | 200 / 403 / 403 |
| DELETE /v1/role/{id} | roles.manage | yes | locked, built-in, own role, covers existing, members | ok / ok / 403 |
| POST /v1/role/{id}/reassign | roles.manage | yes | locked, built-in, own role, covers source, target valid, covers target | ok / ok / 403 |
| DELETE /v1/content-type/{key} | schema.delete | as before | removes typed rows after the delete | as before |
| POST /v1/content-type | schema.edit | as before | clears stale typed rows for the key | as before |
| GET /v1/auth/me | none | as before | lists typed grants | as before |
## Test infrastructure first
1. `rolesAPIRoleRepo`: in-memory `role.Repository` seeded with the three built-ins (same Go function the migration uses) plus the Background roles. Implements `List` (with grants), `Get`, `GetByName`, `Create` (new id, version 1), `Save` (compare-and-swap on version, error `role.ErrRoleConflict`, also sets updated_at/updated_by), `Delete` (`role.ErrRoleHasMembers` if a user in the shared user fake has the role_id), `ReassignMembers`, `RemoveTypedGrants`. A switchable failure per method, call counters for `List`, `Get`, `GetByName`, and a hook to run code between `Get` and `Save` (race tests).
2. User fake with `GetAll`, a role mover shared with the role fake, and users 3, 5, 9, 10 (role_id 0, legacy admin), 12 (unverified Admin), 31-35, 41-45.
3. Content type fake with "events" and "venues". Recording `audit.Recorder` to assert event count, action, category, target, params, changes.
4. HTTP harness: `validator.InitValidator()`, a fresh `chi.NewRouter()` mirroring `NewRoleRouter` in api/router.go (Verifier, apiKeyAuth equivalent, Authenticator, RequireCurrentToken, RequireVerifiedUser, RequireSession on writes, `RequirePermission(role.KeyRolesView|KeyRolesManage)`, AuditContext) over real use cases and the fakes. No live server, no real SQLite, no real SMTP.
5. `assertGate(t, rec, key)` for the 403 contract. New helper `assertRefusal(t, rec, status, code)` for plain-text codes, and `assertEscalation(t, rec, key)` for the `escalation` body plus `X-Escalation-Key`.
6. `grantsOf("content.update=own", "content.update:events=none", ...)` from the content-scope tests, so rows read like the feature.
## A. Domain (no fakes)
7. `GrantSet.Covers`: table of caller grants x candidate grants x type keys, including typed none, caller `update=all, update:article=none` against candidate `update=all` (violation key `content.update:article`), caller with typed-only read, toggles, and the first-violation order. Symmetry check: Covers(x, x) is always true, Owner covers every role, Admin does not cover Owner.
8. Normalised grant list function: duplicates, owner-only on unlocked, unknown keys, typed none kept, order (wildcard then typed by type key).
9. New sentinels exist and map with `errors.Is`: role.ErrRoleHasMembers, ErrRoleNameTaken, ErrRoleLocked, ErrBuiltInRole, ErrBuiltInName, ErrOwnRole, ErrInvalidRoleName, ErrUnknownContentType, and the typed `EscalationError{Key}` read with `errors.As`.
10. Hygiene tests in the style of `*_hygiene_test.go`: `internal/domain/role` imports the stdlib only; `Repository` has no `ListGrants`; no scope-rank comparison outside `internal/domain/role`; `api/router.go` and `api/handlers/role` hold no catalogue key literal and no import of persistence, models, gorm or mailer; the drift test for role.Key constants still passes.
## B. Use cases (fakes only)
11. `ListRoles`/`GetRole`: members through the legacy fallback (user 10 counted in Admin), order by id, normalised grants, updated_by name (display name, else username, null for a deleted user), `List` called once, `ListGrants` does not exist.
12. `CreateRole`: every row of the validation Outline, with `errors.Is` on the sentinel; type keys against the content type fake plus "article"; the covers rows; name taken (case-insensitive, trimmed); the stored role is built_in false, locked false, version 1; one `role.create` event with the right target and params.
13. `UpdateRole`: every row of "Editing a role", "No-escalation" and "above their own grants" Outlines. Assert the order of checks with counters: for a refusal before the version check the fake's `Save` and the content type `List` are never called. No-op PUT: no `Save`, no event, same version. Race: the hook bumps the version between `Get` and `Save`, expect `role.ErrRoleConflict`. Own role resolved for a legacy user (role_id 0).
14. Diff: the example edit gives added/removed/changed exactly as in the feature, plus name and description text changes; created role: added only; deleted role: members param.
15. `DeleteRole`, `ReassignRoleMembers`: every row of their Outlines; `moved` counts; no event when moved is 0; repository failure gives a failed event and no partial move; delete race (`Delete` returns `role.ErrRoleHasMembers`).
16. `DeleteContentType`: removes typed rows after the type delete, bumps only affected roles, does nothing when the delete is refused (`contenttype.ErrHasEntries`, `ErrReferenced`). `CreateContentType`: clears stale typed rows. Both with the role fake's `RemoveTypedGrants` counter.
17. `GetMyAccess`: typed entries incl. none, order, wildcard none omitted.
18. Activity: `audit.Categories()` contains "roles"; `audit.Event.Validate()` accepts the new category; the filter accepts `category=roles`.
## C. HTTP
19. Gate matrix: every row of the first Outline, one subtest per row, stores reset per row. API key writes get `apperr.SessionRequiredErrorMessage`. Live-role scenario (grant added to Viewer between two requests with the same cookie).
20. Each Outline of the feature: validation, name rules, no-escalation, above-own-grants, editing, delete, reassign. Assert status, exact body text, headers (`X-Missing-Permission`, `X-Escalation-Key`), the store after the call, and the recorder.
21. Order: for a Writer, `PUT /v1/role/abc` and `DELETE /v1/role/999` are 403 before 400/404; for a Viewer too. For user 31 an unknown key in the body is 400 before the escalation 403; an escalation on the existing role (Content lead) is 403 before a stale version 409.
22. Responses: list shape, one-role shape (members, updated_at, updated_by), 201 on create, 204 on delete, `{"moved": n}` on reassign. Content-Type header is JSON for success bodies, errors are plain text.
23. Live effect: after PUT on Contributor, user 41's next request on a gated route sees the new grants with the same cookie and an API key created by user 41.
24. `/auth/me`: the Events editor example.
25. Content type delete/create through the real content type router with the role fake attached (rows removed, versions bumped, refused delete leaves rows).
26. Loads per request: a gated request on a role route still loads the user at most once and the role once (`Get`), `List` 0 times by the permission check.
27. Regression: the 2a and 2b suites (enforcement, content scope) stay green after the interface change.
## D. Persistence (existing repository-test style, in-memory migrated DB helper `openMigratedRolesDB`)
28. `RoleRepository`: `List` returns grants for every role in one pass; `Save` compare-and-swap (two saves of version 1: one wins), writes updated_at and updated_by; `Delete` removes rows and refuses with `ErrRoleHasMembers`; `ReassignMembers` returns the count and touches only role_id; `RemoveTypedGrants` removes the five `content.*:<key>` forms only, bumps version once per affected role, leaves wildcard and other types; failure paths (DB closed) for every new method; `Create` of a role with typed rows; seed idempotency unchanged (72 rows, edited built-in kept).
29. `role` and `role_permission` columns: updated_at and updated_by exist after `Migrate` (AutoMigrate list in `persistence/database/migrate.go`), seeded roles have updated_by NULL.
## Existing tests that must change (recheck with `go vet -tags nofrontend ./test/...`)
| File (lines) | Change |
|---|---|
| test/role_support_test.go:85 | `mockRoleRepo.ListGrants` removed. Add `Delete`, `ReassignMembers`, `RemoveTypedGrants`; `List` returns grants; `Save` keeps its conflict behaviour |
| test/roles_content_scope_world_test.go:79-81 and test/roles_content_scope_http_test.go:587, 599, 604, 616, 621 | `listGrantsN` and the `ListGrants` wrapper go away. Replace "0 ListGrants" with "0 List calls by the permission check and /auth/me" |
| test/role_repository_gaps_test.go:45-48 | The `ListGrants` failure case becomes a `List` failure case |
| test/roles_content_scope_usecases_test.go:481, test/roles_content_scope_world_test.go:222, test/custom_content_types_support_test.go:271, 275, test/custom_content_types_support_http_test.go:220, 426, test/custom_content_types_usecases_test.go:154, 596, test/relation_field_type_test.go:127, 234 | `NewCreateContentTypeUseCase` and `NewDeleteContentTypeUseCase` take the role repository (clearing typed rows). Pass a role fake or a no-op one |
| test/activity_domain_test.go:59 | Categories list gains "roles" |
| test/roles_content_scope_http_test.go (the /auth/me typed-grants test) and the feature scenario "/auth/me lists the effective grants" | "typed grants are not listed" is superseded by typed entries incl. none |
| test/api_catalogue_drift_test.go:52 | Add the 6 new routes (GET /v1/role, GET /v1/role/{id}, POST /v1/role, PUT /v1/role/{id}, DELETE /v1/role/{id}, POST /v1/role/{id}/reassign) to `routesNotYetInTheCatalogue`. The existing failure of `TestRouterRoutesAreInTheCatalogue` is not this slice's: report, do not fix |
| any test that lists `role.Repository` method sets or builds a `role.Role` literal with positional fields | recheck after adding UpdatedAt and UpdatedBy |
## Existing features and docs to amend
- features/roles_permissions_content_scope.feature: the scenario "/auth/me lists the effective grants" last line ("typed grants are not listed in this slice") and "Out of scope here" (typed grants written by slice 3: now done); add a "superseded by roles_permissions_roles_api" note in place. Same for its `.qa.md` "Deferred" line 3 and the manual item about editing `role_permission` by hand.
- features/roles_permissions_foundation.feature: the Background sentence "per-type overrides ... deferred to slice 3" and the Repository interface line (list, get by id, get by name, list grants, create, save).
- features/activity-log-producers.qa.md: add the roles category and the four actions.
- docs/api.md: new "Roles" section (routes, codes, headers, response shapes), the `/v1/auth/me` typed entries, the activity category and actions, and the content-type delete/create side effect. docs/architecture.md: role repository methods.
- docs/design: for you to relay, not edited by agents (see Design notes).
## Manual verification
- Edit the Writer role, then POST /v1/role/{id}/reset as Owner with the current version: grants are back to the seed and a second reset is a no-op.
- `task dev:api` on a copy of a migrated database. As the Owner: create "Contributor" (Write own preset grants), edit it, move a member to Writer, delete it. Check the activity log shows role.create, role.edit (with added/removed/changed), role.reassign and role.delete.
- As an Admin: editing Writer answers 403 with X-Missing-Permission roles.edit_owner_only; editing Owner answers role_locked.
- With a role manager who publishes only own entries: granting publish all answers escalation with the key in X-Escalation-Key.
- Open two sessions on the same role, save from both: the second gets role_conflict.
- Restart the server after editing Writer: the edit is still there (seed does not overwrite).
- Delete a content type that has a typed row and check the role_permission rows are gone in the database copy.
- No secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).
+12 -1
View File
@@ -34,6 +34,7 @@ const (
CategorySystem Category = "system"
CategorySites Category = "sites"
CategoryAuthors Category = "authors"
CategoryRoles Category = "roles"
)
type Outcome string
@@ -80,6 +81,10 @@ const (
ActionAuthorCreate = "author.create"
ActionAuthorEdit = "author.edit"
ActionAuthorDelete = "author.delete"
ActionRoleCreate = "role.create"
ActionRoleEdit = "role.edit"
ActionRoleDelete = "role.delete"
ActionRoleReassign = "role.reassign"
ActionMediaUpload = "media.upload"
ActionMediaRename = "media.rename"
ActionMediaDelete = "media.delete"
@@ -118,6 +123,7 @@ const (
TargetRelease = "release"
TargetSite = "site"
TargetAuthor = "author"
TargetRole = "role"
)
const (
@@ -147,6 +153,10 @@ const (
ParamFormat = "format"
ParamQuality = "quality"
ParamTag = "tag"
ParamAdded = "added"
ParamChanged = "changed"
ParamMembers = "members"
ParamMoved = "moved"
FieldTitle = "title"
FieldOnline = "online"
FieldBody = "body"
@@ -158,6 +168,7 @@ const (
FieldFormat = "format"
FieldQuality = "quality"
FieldName = "name"
FieldDescription = "description"
FieldBaseURL = "base_url"
FieldDefaultRobots = "default_robots"
FieldLocale = "locale"
@@ -193,7 +204,7 @@ func ActorTypes() []ActorType {
}
func Categories() []Category {
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors}
return []Category{CategoryArticles, CategoryMedia, CategoryUsers, CategoryAPIKeys, CategoryAuth, CategorySystem, CategorySites, CategoryAuthors, CategoryRoles}
}
func Outcomes() []Outcome {
+22 -7
View File
@@ -3,6 +3,7 @@ package role
import (
"errors"
"strings"
"time"
)
var (
@@ -14,6 +15,18 @@ var (
ErrDuplicateGrant = errors.New("permission granted more than once")
ErrMissingPermission = errors.New("missing permission")
ErrRoleHasMembers = errors.New("role still has members")
ErrRoleNameTaken = errors.New("role name already used")
ErrRoleLocked = errors.New("role is locked")
ErrBuiltInRole = errors.New("role is built in")
ErrBuiltInName = errors.New("built-in role cannot be renamed")
ErrOwnRole = errors.New("cannot change the role you hold")
ErrInvalidRoleName = errors.New("invalid role name")
ErrUnknownContentType = errors.New("unknown content type")
ErrNotBuiltIn = errors.New("role is not built in")
ErrInvalidReassign = errors.New("invalid reassign target")
ErrEscalation = errors.New("grants more than the caller holds")
)
type Scope string
@@ -30,13 +43,15 @@ type Grant struct {
}
type Role struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []Grant `json:"grants"`
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []Grant `json:"grants"`
UpdatedAt time.Time `json:"-"`
UpdatedBy *uint32 `json:"-"`
}
func resolveGrantKey(key string) (Permission, error) {
+45
View File
@@ -0,0 +1,45 @@
package role
type GrantChange struct {
Key string `json:"key"`
From Scope `json:"from"`
To Scope `json:"to"`
}
type GrantDiff struct {
Added []Grant `json:"added"`
Removed []Grant `json:"removed"`
Changed []GrantChange `json:"changed"`
}
func (d GrantDiff) Empty() bool {
return len(d.Added) == 0 && len(d.Removed) == 0 && len(d.Changed) == 0
}
func DiffGrants(before []Grant, after []Grant) GrantDiff {
diff := GrantDiff{Added: []Grant{}, Removed: []Grant{}, Changed: []GrantChange{}}
previous := scopesByKey(before)
current := scopesByKey(after)
for _, grant := range after {
held, present := previous[grant.Key]
if !present {
diff.Added = append(diff.Added, grant)
} else if held != grant.Scope {
diff.Changed = append(diff.Changed, GrantChange{Key: grant.Key, From: held, To: grant.Scope})
}
}
for _, grant := range before {
if _, present := current[grant.Key]; !present {
diff.Removed = append(diff.Removed, grant)
}
}
return diff
}
func scopesByKey(grants []Grant) map[string]Scope {
scopes := make(map[string]Scope, len(grants))
for _, grant := range grants {
scopes[grant.Key] = grant.Scope
}
return scopes
}
+73 -6
View File
@@ -1,5 +1,10 @@
package role
import (
"slices"
"strings"
)
type MissingPermissionError struct {
Key string
}
@@ -33,14 +38,13 @@ func (r Role) GrantSet() GrantSet {
}
func effectiveScope(kind Kind, scope Scope) Scope {
switch {
case scope == ScopeAll:
if scope == ScopeAll {
return ScopeAll
case scope == ScopeOwn && kind == KindScoped:
return ScopeOwn
default:
return ScopeNone
}
if scope == ScopeOwn && kind == KindScoped {
return ScopeOwn
}
return ScopeNone
}
func scopeRank(scope Scope) int {
@@ -112,3 +116,66 @@ func (g GrantSet) Holds(key string) bool {
func (g GrantSet) HoldsAll(key string) bool {
return g.ScopeFor(key, "") == ScopeAll
}
type EscalationError struct {
Key string
}
func (e EscalationError) Error() string {
return ErrEscalation.Error() + ": " + e.Key
}
func (e EscalationError) Is(target error) bool {
return target == ErrEscalation
}
func (g GrantSet) Covers(other GrantSet, typeKeys []string) error {
sortedTypeKeys := slices.Sorted(slices.Values(typeKeys))
for _, permission := range catalogue {
if scopeRank(other.ScopeFor(permission.Key, "")) > scopeRank(g.ScopeFor(permission.Key, "")) {
return EscalationError{Key: permission.Key}
}
if !permission.PerType() {
continue
}
for _, typeKey := range sortedTypeKeys {
if scopeRank(other.ScopeFor(permission.Key, typeKey)) > scopeRank(g.ScopeFor(permission.Key, typeKey)) {
return EscalationError{Key: permission.Key + typeSeparator + typeKey}
}
}
}
return nil
}
func (g GrantSet) Effective() []Grant {
effective := []Grant{}
for _, permission := range catalogue {
if scope := g.wildcard(permission.Key); scope != ScopeNone {
effective = append(effective, Grant{Key: permission.Key, Scope: scope})
}
if permission.PerType() {
effective = append(effective, g.typedGrants(permission.Key)...)
}
}
return effective
}
func (g GrantSet) typedGrants(key string) []Grant {
prefix := key + typeSeparator
composites := []string{}
for composite := range g.scopes {
if strings.HasPrefix(composite, prefix) {
composites = append(composites, composite)
}
}
slices.Sort(composites)
typed := make([]Grant, 0, len(composites))
for _, composite := range composites {
typed = append(typed, Grant{Key: composite, Scope: g.scopes[composite]})
}
return typed
}
func (r Role) EffectiveGrants() []Grant {
return r.GrantSet().Effective()
}
@@ -4,7 +4,9 @@ type Repository interface {
List() ([]Role, error)
Get(id uint32) (Role, error)
GetByName(name string) (Role, error)
ListGrants(roleID uint32) ([]Grant, error)
Create(created Role) (Role, error)
Save(saved Role) (Role, error)
Delete(id uint32) error
ReassignMembers(from uint32, to uint32) (int, error)
RemoveTypedGrants(typeKey string) (int, error)
}
+44
View File
@@ -0,0 +1,44 @@
package role
import (
"slices"
"strings"
)
func NormalizeName(name string) (string, error) {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return "", ErrInvalidRoleName
}
return trimmed, nil
}
func SameName(left string, right string) bool {
return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right))
}
func ValidateGrantTypes(grants []Grant, allowedTypeKeys []string) error {
for _, grant := range grants {
if !isTypedKey(grant.Key) {
continue
}
_, typeKey, err := ParseTypedKey(grant.Key)
if err != nil {
return err
}
if !slices.Contains(allowedTypeKeys, typeKey) {
return ErrUnknownContentType
}
}
return nil
}
func TypedKeys(typeKey string) []string {
keys := []string{}
for _, permission := range catalogue {
if permission.PerType() {
keys = append(keys, permission.Key+typeSeparator+typeKey)
}
}
return keys
}
@@ -1,12 +1,20 @@
package mappers
import (
"time"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/models"
)
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
var editedAt time.Time
if m.EditedAt != nil {
editedAt = m.EditedAt.UTC()
}
return role.Role{
UpdatedAt: editedAt,
UpdatedBy: m.EditedBy,
ID: m.ID,
Name: m.Name,
Description: m.Description,
@@ -18,7 +26,14 @@ func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
}
func RoleToModel(r role.Role) models.Role {
var editedAt *time.Time
if !r.UpdatedAt.IsZero() {
stamp := r.UpdatedAt
editedAt = &stamp
}
return models.Role{
EditedAt: editedAt,
EditedBy: r.UpdatedBy,
ID: r.ID,
Name: r.Name,
Description: r.Description,
@@ -1,12 +1,16 @@
package models
import "time"
type Role struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"not null;size:100;index"`
Description string `gorm:"not null;default:'';size:255"`
BuiltIn bool `gorm:"not null;default:false"`
Locked bool `gorm:"not null;default:false"`
Version uint32 `gorm:"not null;default:1"`
ID uint32 `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"not null;size:100;index"`
Description string `gorm:"not null;default:'';size:255"`
BuiltIn bool `gorm:"not null;default:false"`
Locked bool `gorm:"not null;default:false"`
Version uint32 `gorm:"not null;default:1"`
EditedAt *time.Time `gorm:"column:updated_at"`
EditedBy *uint32 `gorm:"column:updated_by"`
}
type RolePermission struct {
@@ -1,11 +1,14 @@
package persistence
import (
"strings"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type RoleRepository struct {
@@ -37,9 +40,17 @@ func (r *RoleRepository) List() ([]role.Role, error) {
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
return nil, err
}
var rows []entity.RolePermission
if err := r.db.Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
rowsByRole := make(map[uint32][]entity.RolePermission, len(found))
for _, row := range rows {
rowsByRole[row.RoleID] = append(rowsByRole[row.RoleID], row)
}
roles := make([]role.Role, 0, len(found))
for _, row := range found {
roles = append(roles, mappers.RoleToDomain(row, nil))
roles = append(roles, mappers.RoleToDomain(row, rowsByRole[row.ID]))
}
return roles, nil
}
@@ -60,18 +71,28 @@ func (r *RoleRepository) GetByName(name string) (role.Role, error) {
return r.withGrants(found)
}
func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) {
rows, err := r.grantRows(roleID)
if err != nil {
return nil, err
func ensureNameUnused(tx *gorm.DB, name string, excludedID uint32) error {
var taken int64
query := tx.Model(&entity.Role{}).Where("LOWER(name) = LOWER(?)", strings.TrimSpace(name))
if excludedID != 0 {
query = query.Where("id <> ?", excludedID)
}
return mappers.GrantsToDomain(rows), nil
if err := query.Count(&taken).Error; err != nil {
return err
}
if taken > 0 {
return role.ErrRoleNameTaken
}
return nil
}
func (r *RoleRepository) Create(created role.Role) (role.Role, error) {
model := mappers.RoleToModel(created)
model.ID = 0
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := ensureNameUnused(tx, created.Name, 0); err != nil {
return err
}
if err := tx.Create(&model).Error; err != nil {
return err
}
@@ -92,9 +113,12 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound)
}
if err := ensureNameUnused(tx, saved.Name, saved.ID); err != nil {
return err
}
update := tx.Model(&entity.Role{}).
Where("id = ? AND version = ?", saved.ID, saved.Version).
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1})
Updates(map[string]any{"name": saved.Name, "description": saved.Description, "version": saved.Version + 1, "updated_at": mappers.RoleToModel(saved).EditedAt, "updated_by": saved.UpdatedBy})
if update.Error != nil {
return update.Error
}
@@ -116,4 +140,66 @@ func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
return r.Get(saved.ID)
}
func (r *RoleRepository) Delete(id uint32) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&entity.Role{}, id).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound)
}
var members int64
if err := tx.Model(&entity.User{}).Where("role_id = ?", id).Count(&members).Error; err != nil {
return err
}
if members > 0 {
return role.ErrRoleHasMembers
}
if err := tx.Where("role_id = ?", id).Delete(&entity.RolePermission{}).Error; err != nil {
return err
}
return tx.Delete(&entity.Role{}, id).Error
})
}
func (r *RoleRepository) ReassignMembers(from uint32, to uint32) (int, error) {
moved := r.db.Model(&entity.User{}).
Where("role_id = ? AND EXISTS (SELECT 1 FROM roles WHERE id = ?)", from, to).
UpdateColumn("role_id", to)
if moved.Error != nil {
return 0, moved.Error
}
if moved.RowsAffected > 0 {
return int(moved.RowsAffected), nil
}
var targets int64
if err := r.db.Model(&entity.Role{}).Where("id = ?", to).Count(&targets).Error; err != nil {
return 0, err
}
if targets == 0 {
return 0, role.ErrInvalidReassign
}
return 0, nil
}
func (r *RoleRepository) RemoveTypedGrants(typeKey string) (int, error) {
affected := 0
err := r.db.Transaction(func(tx *gorm.DB) error {
var roleIDs []uint32
doomed := role.TypedKeys(typeKey)
if err := tx.Model(&entity.RolePermission{}).Where("permission_key IN ?", doomed).Distinct().Pluck("role_id", &roleIDs).Error; err != nil {
return err
}
if len(roleIDs) == 0 {
return nil
}
if err := tx.Where("permission_key IN ?", doomed).Delete(&entity.RolePermission{}).Error; err != nil {
return err
}
affected = len(roleIDs)
return tx.Model(&entity.Role{}).Where("id IN ?", roleIDs).UpdateColumn("version", gorm.Expr("version + 1")).Error
})
if err != nil {
return 0, err
}
return affected, nil
}
var _ role.Repository = &RoleRepository{}
@@ -3,12 +3,14 @@ package useCases
import (
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"errors"
"time"
)
type CreateContentTypeUseCase struct {
contentTypes contenttype.Repository
roles role.Repository
now func() time.Time
}
@@ -19,8 +21,8 @@ type CreateContentTypeCommand struct {
Fields []contenttype.Field
}
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, now func() time.Time) *CreateContentTypeUseCase {
return &CreateContentTypeUseCase{contentTypes, now}
func NewCreateContentTypeUseCase(contentTypes contenttype.Repository, roles role.Repository, now func() time.Time) *CreateContentTypeUseCase {
return &CreateContentTypeUseCase{contentTypes, roles, now}
}
func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (contenttype.ContentType, error) {
@@ -45,7 +47,7 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
return contenttype.ContentType{}, err
}
created := u.now()
return u.contentTypes.Create(contenttype.ContentType{
made, err := u.contentTypes.Create(contenttype.ContentType{
Key: command.Key,
Name: command.Name,
Icon: icon,
@@ -53,6 +55,13 @@ func (u *CreateContentTypeUseCase) Create(command CreateContentTypeCommand) (con
CreatedAt: created,
UpdatedAt: created,
})
if err != nil {
return contenttype.ContentType{}, err
}
if _, err := u.roles.RemoveTypedGrants(command.Key); err != nil {
return contenttype.ContentType{}, err
}
return made, nil
}
func (u *CreateContentTypeUseCase) ensureKeyFree(key string) error {
@@ -0,0 +1,56 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
)
type CreateRoleUseCase struct {
roles role.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
viewer roleViewer
}
type CreateRoleCommand struct {
Caller ContentCaller
Name string
Description string
Grants []role.Grant
}
func NewCreateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *CreateRoleUseCase {
return &CreateRoleUseCase{roles: roles, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *CreateRoleUseCase) Create(ctx context.Context, command CreateRoleCommand) (RoleView, error) {
name, err := role.NormalizeName(command.Name)
if err != nil {
return RoleView{}, err
}
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
return RoleView{}, err
}
draft := role.Role{Name: name, Description: command.Description, Version: 1, Grants: normalizedGrants(command.Grants)}
if err := ensureCovered(command.Caller, u.contentTypes, draft); err != nil {
return RoleView{}, err
}
if err := ensureNameFree(u.roles, name, 0); err != nil {
return RoleView{}, err
}
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
event.Target = audit.Target{Type: audit.TargetRole, Name: name}
created, err := u.roles.Create(draft)
if err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return RoleView{}, err
}
event.Target = roleTarget(created)
event.Params[audit.ParamAdded] = created.EffectiveGrants()
u.recorder.Record(ctx, event)
return u.viewer.one(created, 0)
}
@@ -3,15 +3,17 @@ package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/role"
)
type DeleteContentTypeUseCase struct {
contentTypes contenttype.Repository
entries entry.Repository
roles role.Repository
}
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository) *DeleteContentTypeUseCase {
return &DeleteContentTypeUseCase{contentTypes, entries}
func NewDeleteContentTypeUseCase(contentTypes contenttype.Repository, entries entry.Repository, roles role.Repository) *DeleteContentTypeUseCase {
return &DeleteContentTypeUseCase{contentTypes, entries, roles}
}
func (u *DeleteContentTypeUseCase) Delete(key string) error {
@@ -35,5 +37,9 @@ func (u *DeleteContentTypeUseCase) Delete(key string) error {
if count > 0 {
return contenttype.ErrHasEntries
}
return u.contentTypes.Delete(target.ID)
if err := u.contentTypes.Delete(target.ID); err != nil {
return err
}
_, err = u.roles.RemoveTypedGrants(target.Key)
return err
}
@@ -0,0 +1,69 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
)
type DeleteRoleUseCase struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
viewer roleViewer
}
type DeleteRoleCommand struct {
Caller ContentCaller
ID uint32
}
func NewDeleteRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *DeleteRoleUseCase {
return &DeleteRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *DeleteRoleUseCase) Delete(ctx context.Context, command DeleteRoleCommand) error {
target, err := u.roles.Get(command.ID)
if err != nil {
return err
}
if err := u.check(command.Caller, target); err != nil {
return err
}
event := audit.NewEvent(audit.ActionRoleDelete, audit.CategoryRoles)
event.Target = roleTarget(target)
event.Params[audit.ParamMembers] = 0
if err := u.roles.Delete(target.ID); err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return err
}
u.recorder.Record(ctx, event)
return nil
}
func (u *DeleteRoleUseCase) check(caller ContentCaller, target role.Role) error {
if err := ensureEditable(target); err != nil {
return err
}
if err := ensureNotBuiltIn(target); err != nil {
return err
}
if err := ensureNotOwnRole(u.roles, u.users, caller, target); err != nil {
return err
}
if err := ensureCovered(caller, u.contentTypes, target); err != nil {
return err
}
members, err := u.viewer.members(target.ID)
if err != nil {
return err
}
if members > 0 {
return role.ErrRoleHasMembers
}
return nil
}
@@ -46,15 +46,5 @@ func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
if err != nil {
return Access{}, err
}
return Access{Role: resolved, Permissions: effectiveGrants(resolved.GrantSet())}, nil
}
func effectiveGrants(grants role.GrantSet) []role.Grant {
effective := []role.Grant{}
for _, permission := range role.Catalogue() {
if scope := grants.ScopeFor(permission.Key, ""); scope != role.ScopeNone {
effective = append(effective, role.Grant{Key: permission.Key, Scope: scope})
}
}
return effective
return Access{Role: resolved, Permissions: resolved.EffectiveGrants()}, nil
}
@@ -0,0 +1,28 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
)
type GetRoleUseCase struct {
roles role.Repository
viewer roleViewer
}
func NewGetRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *GetRoleUseCase {
return &GetRoleUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *GetRoleUseCase) Get(id uint32) (RoleView, error) {
found, err := u.roles.Get(id)
if err != nil {
return RoleView{}, err
}
members, err := u.viewer.members(found.ID)
if err != nil {
return RoleView{}, err
}
return u.viewer.one(found, members)
}
@@ -0,0 +1,24 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
)
type ListRolesUseCase struct {
roles role.Repository
viewer roleViewer
}
func NewListRolesUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository) *ListRolesUseCase {
return &ListRolesUseCase{roles: roles, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *ListRolesUseCase) List() ([]RoleView, error) {
found, err := u.roles.List()
if err != nil {
return nil, err
}
return u.viewer.views(found)
}
@@ -0,0 +1,86 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
)
type ReassignRoleMembersUseCase struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
}
type ReassignRoleMembersCommand struct {
Caller ContentCaller
SourceID uint32
TargetID uint32
}
func NewReassignRoleMembersUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder) *ReassignRoleMembersUseCase {
return &ReassignRoleMembersUseCase{roles, users, contentTypes, recorder}
}
func (u *ReassignRoleMembersUseCase) Reassign(ctx context.Context, command ReassignRoleMembersCommand) (int, error) {
source, err := u.roles.Get(command.SourceID)
if err != nil {
return 0, err
}
if err := u.checkSource(command.Caller, source); err != nil {
return 0, err
}
if err := u.checkTarget(command, source); err != nil {
return 0, err
}
event := audit.NewEvent(audit.ActionRoleReassign, audit.CategoryRoles)
event.Target = roleTarget(source)
event.Params[audit.ParamFrom] = source.ID
event.Params[audit.ParamTo] = command.TargetID
event.Params[audit.ParamMoved] = 0
moved, err := u.roles.ReassignMembers(source.ID, command.TargetID)
if err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return 0, err
}
if moved > 0 {
event.Params[audit.ParamMoved] = moved
u.recorder.Record(ctx, event)
}
return moved, nil
}
func (u *ReassignRoleMembersUseCase) checkSource(caller ContentCaller, source role.Role) error {
if err := ensureEditable(source); err != nil {
return err
}
if err := ensureNotBuiltIn(source); err != nil {
return err
}
if err := ensureNotOwnRole(u.roles, u.users, caller, source); err != nil {
return err
}
return ensureCovered(caller, u.contentTypes, source)
}
func (u *ReassignRoleMembersUseCase) checkTarget(command ReassignRoleMembersCommand, source role.Role) error {
if command.TargetID == 0 || command.TargetID == source.ID {
return role.ErrInvalidReassign
}
target, err := u.roles.Get(command.TargetID)
if errors.Is(err, role.ErrRoleNotFound) {
return role.ErrInvalidReassign
}
if err != nil {
return err
}
if target.Locked {
return role.ErrInvalidReassign
}
return ensureCovered(command.Caller, u.contentTypes, target)
}
@@ -0,0 +1,170 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"time"
)
type UpdateRoleUseCase struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
recorder audit.Recorder
now func() time.Time
viewer roleViewer
}
type UpdateRoleCommand struct {
Caller ContentCaller
ID uint32
Name string
Description string
Grants []role.Grant
Version uint32
}
type ResetRoleCommand struct {
Caller ContentCaller
ID uint32
Version uint32
}
func NewUpdateRoleUseCase(roles role.Repository, users user.Repository, contentTypes contenttype.Repository, recorder audit.Recorder, now func() time.Time) *UpdateRoleUseCase {
return &UpdateRoleUseCase{roles: roles, users: users, contentTypes: contentTypes, recorder: recorder, now: now, viewer: roleViewer{roles, users, contentTypes}}
}
func (u *UpdateRoleUseCase) Update(ctx context.Context, command UpdateRoleCommand) (RoleView, error) {
target, err := u.roles.Get(command.ID)
if err != nil {
return RoleView{}, err
}
if err := u.checkTarget(command, target); err != nil {
return RoleView{}, err
}
name, err := role.NormalizeName(command.Name)
if err != nil {
return RoleView{}, err
}
if err := validateRoleGrants(u.contentTypes, command.Grants); err != nil {
return RoleView{}, err
}
after := target
after.Name = name
after.Description = command.Description
after.Grants = normalizedGrants(command.Grants)
if err := ensureCovered(command.Caller, u.contentTypes, after); err != nil {
return RoleView{}, err
}
if err := ensureNameFree(u.roles, name, target.ID); err != nil {
return RoleView{}, err
}
return u.write(ctx, command.Caller, target, after)
}
func (u *UpdateRoleUseCase) Reset(ctx context.Context, command ResetRoleCommand) (RoleView, error) {
target, err := u.roles.Get(command.ID)
if err != nil {
return RoleView{}, err
}
if err := ensureEditable(target); err != nil {
return RoleView{}, err
}
if !target.BuiltIn {
return RoleView{}, role.ErrNotBuiltIn
}
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
return RoleView{}, err
}
if command.Version != target.Version {
return RoleView{}, role.ErrRoleConflict
}
seeded, found := seededRole(target.Name)
if !found {
return RoleView{}, role.ErrNotBuiltIn
}
after := target
after.Description = seeded.Description
after.Grants = seeded.EffectiveGrants()
return u.write(ctx, command.Caller, target, after)
}
func (u *UpdateRoleUseCase) checkTarget(command UpdateRoleCommand, target role.Role) error {
if err := ensureEditable(target); err != nil {
return err
}
if target.BuiltIn {
if err := requireOwnerOnlyEditing(command.Caller); err != nil {
return err
}
if renamesBuiltIn(command.Name, target) {
return role.ErrBuiltInName
}
}
if err := ensureNotOwnRole(u.roles, u.users, command.Caller, target); err != nil {
return err
}
if err := ensureCovered(command.Caller, u.contentTypes, target); err != nil {
return err
}
if command.Version != target.Version {
return role.ErrRoleConflict
}
return nil
}
func (u *UpdateRoleUseCase) write(ctx context.Context, caller ContentCaller, before role.Role, after role.Role) (RoleView, error) {
members, err := u.viewer.members(before.ID)
if err != nil {
return RoleView{}, err
}
live, err := u.viewer.liveGrants(before.EffectiveGrants())
if err != nil {
return RoleView{}, err
}
diff := role.DiffGrants(live, after.Grants)
if diff.Empty() && before.Name == after.Name && before.Description == after.Description {
return u.viewer.one(before, members)
}
event := audit.NewEvent(audit.ActionRoleEdit, audit.CategoryRoles)
event.Target = roleTarget(before)
editor := caller.UserID
after.UpdatedAt = u.now().UTC()
after.UpdatedBy = &editor
saved, err := u.roles.Save(after)
if err != nil {
recordRoleWriteFailure(ctx, u.recorder, event, err)
return RoleView{}, err
}
event.Target = roleTarget(saved)
event.Params = grantsParams(diff, members)
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldName, before.Name, saved.Name)
event.Changes = audit.AppendTextChange(event.Changes, audit.FieldDescription, before.Description, saved.Description)
u.recorder.Record(ctx, event)
return u.viewer.one(saved, members)
}
func requireOwnerOnlyEditing(caller ContentCaller) error {
if !caller.Grants.Holds(role.KeyRolesEditOwnerOnly) {
return role.MissingPermissionError{Key: role.KeyRolesEditOwnerOnly}
}
return nil
}
func renamesBuiltIn(requested string, target role.Role) bool {
trimmed, err := role.NormalizeName(requested)
return err != nil || trimmed != target.Name
}
func seededRole(name string) (role.Role, bool) {
for _, seeded := range role.BuiltInRoles() {
if seeded.Name == name {
return seeded, true
}
}
return role.Role{}, false
}
@@ -0,0 +1,117 @@
package useCases
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/builtintype"
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"context"
"errors"
"slices"
)
func roleTarget(found role.Role) audit.Target {
return audit.NewTarget(audit.TargetRole, found.ID, found.Name)
}
func allowedTypeKeys(contentTypes contenttype.Repository) ([]string, error) {
types, err := contentTypes.List()
if err != nil {
return nil, err
}
keys := []string{builtintype.KeyArticle}
for _, contentType := range types {
keys = append(keys, contentType.Key)
}
slices.Sort(keys)
return keys, nil
}
func ensureCovered(caller ContentCaller, contentTypes contenttype.Repository, candidate role.Role) error {
typeKeys, err := allowedTypeKeys(contentTypes)
if err != nil {
return err
}
return caller.Grants.Covers(candidate.GrantSet(), typeKeys)
}
func ensureNotOwnRole(roles role.Repository, users user.Repository, caller ContentCaller, target role.Role) error {
account, err := users.Get(caller.UserID)
if err != nil {
return err
}
own, err := resolveRole(roles, account)
if errors.Is(err, role.ErrRoleNotFound) {
return nil
}
if err != nil {
return err
}
if own.ID == target.ID {
return role.ErrOwnRole
}
return nil
}
func ensureEditable(target role.Role) error {
if target.Locked {
return role.ErrRoleLocked
}
return nil
}
func ensureNotBuiltIn(target role.Role) error {
if target.BuiltIn {
return role.ErrBuiltInRole
}
return nil
}
func ensureNameFree(roles role.Repository, name string, excludedID uint32) error {
existing, err := roles.List()
if err != nil {
return err
}
for _, other := range existing {
if other.ID != excludedID && role.SameName(other.Name, name) {
return role.ErrRoleNameTaken
}
}
return nil
}
func validateRoleGrants(contentTypes contenttype.Repository, grants []role.Grant) error {
if err := role.ValidateGrants(false, grants); err != nil {
return err
}
typeKeys, err := allowedTypeKeys(contentTypes)
if err != nil {
return err
}
return role.ValidateGrantTypes(grants, typeKeys)
}
func normalizedGrants(grants []role.Grant) []role.Grant {
return role.Role{Grants: grants}.EffectiveGrants()
}
var writeRefusals = []error{role.ErrRoleConflict, role.ErrRoleHasMembers, role.ErrRoleNotFound, role.ErrRoleNameTaken, role.ErrInvalidReassign}
func recordRoleWriteFailure(ctx context.Context, recorder audit.Recorder, event audit.Event, err error) {
for _, refusal := range writeRefusals {
if errors.Is(err, refusal) {
return
}
}
recordFailure(ctx, recorder, event, err)
}
func grantsParams(diff role.GrantDiff, members int) map[string]any {
return map[string]any{
audit.ParamAdded: diff.Added,
audit.ParamRemoved: diff.Removed,
audit.ParamChanged: diff.Changed,
audit.ParamMembers: members,
}
}
@@ -0,0 +1,143 @@
package useCases
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"errors"
"slices"
"time"
)
type RoleEditor struct {
ID uint32 `json:"id"`
Name string `json:"name"`
}
type RoleView struct {
role.Role
Members int `json:"members"`
UpdatedAt *time.Time `json:"updated_at"`
UpdatedBy *RoleEditor `json:"updated_by"`
}
type roleViewer struct {
roles role.Repository
users user.Repository
contentTypes contenttype.Repository
}
func (v roleViewer) liveGrants(grants []role.Grant) ([]role.Grant, error) {
typeKeys, err := allowedTypeKeys(v.contentTypes)
if err != nil {
return nil, err
}
live := make([]role.Grant, 0, len(grants))
for _, grant := range grants {
if _, typeKey, parseErr := role.ParseTypedKey(grant.Key); parseErr == nil && !slices.Contains(typeKeys, typeKey) {
continue
}
live = append(live, grant)
}
return live, nil
}
func (v roleViewer) views(found []role.Role) ([]RoleView, error) {
counts, err := memberCounts(v.roles, v.users.GetAll())
if err != nil {
return nil, err
}
editors, err := v.editors(found)
if err != nil {
return nil, err
}
views := make([]RoleView, 0, len(found))
for _, item := range found {
view := toRoleView(item, counts[item.ID], editors)
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
return nil, err
}
views = append(views, view)
}
return views, nil
}
func (v roleViewer) one(found role.Role, members int) (RoleView, error) {
editors, err := v.editors([]role.Role{found})
if err != nil {
return RoleView{}, err
}
view := toRoleView(found, members, editors)
if view.Grants, err = v.liveGrants(view.Grants); err != nil {
return RoleView{}, err
}
return view, nil
}
func (v roleViewer) members(roleID uint32) (int, error) {
counts, err := memberCounts(v.roles, v.users.GetAll())
if err != nil {
return 0, err
}
return counts[roleID], nil
}
func (v roleViewer) editors(found []role.Role) (map[uint32]string, error) {
ids := []uint32{}
for _, item := range found {
if item.UpdatedBy != nil {
ids = append(ids, *item.UpdatedBy)
}
}
names := map[uint32]string{}
if len(ids) == 0 {
return names, nil
}
editors, err := v.users.FindByIDs(ids)
if err != nil {
return nil, err
}
for _, editor := range editors {
names[editor.ID] = editor.NameOrUsername()
}
return names, nil
}
func toRoleView(found role.Role, members int, editors map[uint32]string) RoleView {
view := RoleView{Role: found, Members: members}
view.Grants = found.EffectiveGrants()
if !found.UpdatedAt.IsZero() {
stamp := found.UpdatedAt.UTC()
view.UpdatedAt = &stamp
}
if found.UpdatedBy != nil {
if name, present := editors[*found.UpdatedBy]; present {
view.UpdatedBy = &RoleEditor{ID: *found.UpdatedBy, Name: name}
}
}
return view
}
func memberCounts(roles role.Repository, accounts []user.User) (map[uint32]int, error) {
counts := map[uint32]int{}
legacyIDs := map[user.Role]uint32{}
for _, account := range accounts {
id := account.RoleID
if id == 0 {
resolved, cached := legacyIDs[account.Role]
if !cached {
found, err := resolveRole(roles, account)
if err != nil && !errors.Is(err, role.ErrRoleNotFound) {
return nil, err
}
resolved = found.ID
legacyIDs[account.Role] = resolved
}
id = resolved
}
if id != 0 {
counts[id]++
}
}
return counts, nil
}
+1 -1
View File
@@ -56,7 +56,7 @@ func TestActivityDomain(t *testing.T) {
wanted []string
}{
{"actor.type", activityStrings(audit.ActorTypes()), []string{"anonymous", "api_key", "system", "user"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "authors", "media", "sites", "system", "users"}},
{"category", activityStrings(audit.Categories()), []string{"api_keys", "articles", "auth", "authors", "media", "roles", "sites", "system", "users"}},
{"outcome", activityStrings(audit.Outcomes()), []string{"failed", "success"}},
{"source", activityStrings(audit.Sources()), []string{"api_key", "system", "web"}},
{"change.kind", activityStrings(audit.ChangeKinds()), []string{"body", "text"}},
+7
View File
@@ -54,6 +54,7 @@ var routesNotYetInTheCatalogue = []string{
"DELETE /v1/content-type/{key}/entry/{id}",
"DELETE /v1/image-variants/{name}",
"DELETE /v1/media/{id}/variants/{name}",
"DELETE /v1/role/{id}",
"GET /v1/builtin-type",
"GET /v1/builtin-type/{key}",
"GET /v1/content-type",
@@ -65,6 +66,8 @@ var routesNotYetInTheCatalogue = []string{
"GET /v1/media/{id}/variants",
"GET /v1/user/lookup",
"GET /v1/permission",
"GET /v1/role",
"GET /v1/role/{id}",
"PATCH /v1/image-variants/{name}",
"POST /v1/content-type",
"POST /v1/content-type/{key}/entry",
@@ -73,9 +76,13 @@ var routesNotYetInTheCatalogue = []string{
"POST /v1/image-variants/{name}/purge",
"POST /v1/media/{id}/variants",
"POST /v1/media/{id}/variants/{name}",
"POST /v1/role",
"POST /v1/role/{id}/reassign",
"POST /v1/role/{id}/reset",
"PUT /v1/content-type/{key}",
"PUT /v1/content-type/{key}/entry/{id}",
"PUT /v1/image-variants/{name}",
"PUT /v1/role/{id}",
}
func TestRouterRoutesAreInTheCatalogue(t *testing.T) {
@@ -217,7 +217,7 @@ func TestContentTypeIconUseCase(t *testing.T) {
repo := newMockContentTypeRepo()
repo.types[1] = contenttype.ContentType{ID: 1, Key: "dish", Name: "Dish", Fields: fields}
bad := icon
_, err := useCases.NewCreateContentTypeUseCase(repo, now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
_, err := useCases.NewCreateContentTypeUseCase(repo, newMockRoleRepo(), now).Create(useCases.CreateContentTypeCommand{Key: "recipe", Name: "Recipe", Icon: &bad, Fields: fields})
if !errors.Is(err, contenttype.ErrInvalidIcon) || repo.createCalls != 0 {
t.Errorf("create: err %v, calls %d", err, repo.createCalls)
}
@@ -423,7 +423,7 @@ func TestBuiltinTypeHTTP(t *testing.T) {
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(newMockAPIKeyRepo(), generator, users, now),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(panickingContentTypeRepo{}, newMockRoleRepo(), now),
CreateEntryUseCase: useCases.NewCreateEntryUseCase(panickingContentTypeRepo{}, panickingEntryRepo{}, newMockImageRepo(), now),
}
router := chi.NewRouter()
+2 -2
View File
@@ -268,11 +268,11 @@ func newContentTypeEnv(t *testing.T) *contentTypeEnv {
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
CheckPermissionUseCase: useCases.NewCheckPermissionUseCase(newSeededMockRoleRepo()),
AuthenticateAPIKeyUseCase: useCases.NewAuthenticateAPIKeyUseCase(env.keys, env.generator, env.users, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, now),
CreateContentTypeUseCase: useCases.NewCreateContentTypeUseCase(env.types, newMockRoleRepo(), now),
ListContentTypesUseCase: useCases.NewListContentTypesUseCase(env.types),
GetContentTypeUseCase: useCases.NewGetContentTypeUseCase(env.types),
UpdateContentTypeUseCase: useCases.NewUpdateContentTypeUseCase(env.types, env.entries, now),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries),
DeleteContentTypeUseCase: useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()),
ListUsersAndInvitesUseCase: useCases.NewListUsersAndInvitesUseCase(env.users, newInviteFakeRepo()),
ListBuiltinTypesUseCase: useCases.NewListBuiltinTypesUseCase(),
GetBuiltinTypeUseCase: useCases.NewGetBuiltinTypeUseCase(),
+2 -2
View File
@@ -151,7 +151,7 @@ func TestDeleteContentTypeUseCase(t *testing.T) {
t.Run("Use case: DeleteContentType with entries returns ErrHasEntries and nothing is deleted", func(t *testing.T) {
fixture, stored := newContentTypeFixture(t)
_, _ = fixture.entries.Create(entry.Entry{ContentTypeID: stored.ID, Data: map[string]any{}})
remove := useCases.NewDeleteContentTypeUseCase(fixture.types, fixture.entries)
remove := useCases.NewDeleteContentTypeUseCase(fixture.types, fixture.entries, newMockRoleRepo())
err := remove.Delete("recipe")
@@ -593,7 +593,7 @@ func TestContentTypeNameRule(t *testing.T) {
t.Run("The use cases enforce it for non-HTTP callers", func(t *testing.T) {
fixture, existing := newContentTypeFixture(t)
create := useCases.NewCreateContentTypeUseCase(fixture.types, fixture.now)
create := useCases.NewCreateContentTypeUseCase(fixture.types, newMockRoleRepo(), fixture.now)
update := useCases.NewUpdateContentTypeUseCase(fixture.types, fixture.entries, fixture.now)
if _, err := create.Create(useCases.CreateContentTypeCommand{Key: "fresh", Name: " ", Fields: existing.Fields}); !errors.Is(err, contenttype.ErrInvalidName) {
t.Errorf("create blank: %v", err)
+2 -2
View File
@@ -124,7 +124,7 @@ func TestRelationFieldUseCases(t *testing.T) {
t.Run("DeleteContentType returns ErrReferenced for a target type", func(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries)
remove := useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo())
if err := remove.Delete("venue"); !errors.Is(err, contenttype.ErrReferenced) {
t.Errorf("venue: %v", err)
@@ -231,7 +231,7 @@ func TestRelationFieldUseCaseEdges(t *testing.T) {
env := newRelationUseCaseEnv(t, contenttype.RelationOneToMany)
env.types.Create(contenttype.ContentType{Key: "node", Name: "Node", Fields: []contenttype.Field{{Key: "parent", Type: contenttype.TypeRelation, Relation: &contenttype.Relation{ContentType: "node", Kind: contenttype.RelationOneToMany}}}})
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries).Delete("node"); err != nil {
if err := useCases.NewDeleteContentTypeUseCase(env.types, env.entries, newMockRoleRepo()).Delete("node"); err != nil {
t.Errorf("err %v", err)
}
})
+3 -3
View File
@@ -42,10 +42,10 @@ func TestRoleRepositoryFailures(t *testing.T) {
}
})
t.Run("ListGrants and role loading fail when grants cannot be read", func(t *testing.T) {
t.Run("List and role loading fail when grants cannot be read", func(t *testing.T) {
repo := broken(t, "role_permissions")
if _, err := repo.ListGrants(1); err == nil {
t.Error("ListGrants: expected error")
if _, err := repo.List(); err == nil {
t.Error("List: expected error")
}
if _, err := repo.Get(1); err == nil {
t.Error("Get: expected error")
+91 -15
View File
@@ -12,13 +12,16 @@ const (
)
type mockRoleRepo struct {
roles map[uint32]role.Role
nextID uint32
fail failSwitch
roles map[uint32]role.Role
nextID uint32
fail failSwitch
users *mockUserRepo
calls map[string]int
beforeSave func()
}
func newMockRoleRepo() *mockRoleRepo {
return &mockRoleRepo{roles: map[uint32]role.Role{}}
return &mockRoleRepo{roles: map[uint32]role.Role{}, calls: map[string]int{}}
}
func newSeededMockRoleRepo() *mockRoleRepo {
@@ -44,6 +47,7 @@ func (m *mockRoleRepo) sortedIDs() []uint32 {
}
func (m *mockRoleRepo) List() ([]role.Role, error) {
m.calls["List"]++
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
@@ -55,6 +59,7 @@ func (m *mockRoleRepo) List() ([]role.Role, error) {
}
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
m.calls["Get"]++
if err := m.fail.trigger("Get"); err != nil {
return role.Role{}, err
}
@@ -66,6 +71,7 @@ func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
}
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
m.calls["GetByName"]++
if err := m.fail.trigger("GetByName"); err != nil {
return role.Role{}, err
}
@@ -82,18 +88,8 @@ func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
return *match, nil
}
func (m *mockRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
if err := m.fail.trigger("ListGrants"); err != nil {
return nil, err
}
found, ok := m.roles[roleID]
if !ok {
return nil, role.ErrRoleNotFound
}
return slices.Clone(found.Grants), nil
}
func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
m.calls["Create"]++
if err := m.fail.trigger("Create"); err != nil {
return role.Role{}, err
}
@@ -104,9 +100,15 @@ func (m *mockRoleRepo) Create(created role.Role) (role.Role, error) {
}
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
m.calls["Save"]++
if err := m.fail.trigger("Save"); err != nil {
return role.Role{}, err
}
if m.beforeSave != nil {
hook := m.beforeSave
m.beforeSave = nil
hook()
}
stored, ok := m.roles[saved.ID]
if !ok {
return role.Role{}, role.ErrRoleNotFound
@@ -118,8 +120,82 @@ func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
stored.Description = saved.Description
stored.Version++
stored.Grants = slices.Clone(saved.Grants)
stored.UpdatedAt = saved.UpdatedAt
stored.UpdatedBy = saved.UpdatedBy
m.roles[saved.ID] = stored
return stored, nil
}
func (m *mockRoleRepo) memberCount(roleID uint32) int {
if m.users == nil {
return 0
}
count := 0
for _, member := range m.users.users {
if member.RoleID == roleID {
count++
}
}
return count
}
func (m *mockRoleRepo) Delete(id uint32) error {
m.calls["Delete"]++
if err := m.fail.trigger("Delete"); err != nil {
return err
}
if _, ok := m.roles[id]; !ok {
return role.ErrRoleNotFound
}
if m.memberCount(id) > 0 {
return role.ErrRoleHasMembers
}
delete(m.roles, id)
return nil
}
func (m *mockRoleRepo) ReassignMembers(from uint32, to uint32) (int, error) {
m.calls["ReassignMembers"]++
if err := m.fail.trigger("ReassignMembers"); err != nil {
return 0, err
}
if m.users == nil {
return 0, nil
}
moved := 0
for id, member := range m.users.users {
if member.RoleID == from {
member.RoleID = to
m.users.users[id] = member
moved++
}
}
return moved, nil
}
func (m *mockRoleRepo) RemoveTypedGrants(typeKey string) (int, error) {
m.calls["RemoveTypedGrants"]++
if err := m.fail.trigger("RemoveTypedGrants"); err != nil {
return 0, err
}
doomed := role.TypedKeys(typeKey)
affected := 0
for id, stored := range m.roles {
kept := []role.Grant{}
for _, grant := range stored.Grants {
if !slices.Contains(doomed, grant.Key) {
kept = append(kept, grant)
}
}
if len(kept) == len(stored.Grants) {
continue
}
stored.Grants = kept
stored.Version++
m.roles[id] = stored
affected++
}
return affected, nil
}
var _ role.Repository = &mockRoleRepo{}
+233
View File
@@ -0,0 +1,233 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"errors"
"reflect"
"testing"
)
func escalationKeyOf(t *testing.T, err error) string {
t.Helper()
var escalation role.EscalationError
if !errors.As(err, &escalation) || !errors.Is(err, role.ErrEscalation) {
t.Fatalf("expected an EscalationError, got %v", err)
}
return escalation.Key
}
func TestGrantSetCovers(t *testing.T) {
typeKeys := []string{"article", "events", "venues"}
builtIns := role.BuiltInRoles()
ownerSet, adminSet, writerSet := builtIns[0].GrantSet(), builtIns[1].GrantSet(), builtIns[2].GrantSet()
cases := []struct {
name string
caller role.GrantSet
candidate role.GrantSet
wantKey string
}{
{"owner covers admin", ownerSet, adminSet, ""},
{"owner covers writer", ownerSet, writerSet, ""},
{"admin does not cover owner and names the first owner-only key", adminSet, ownerSet, role.KeySettingsDeleteSite},
{"admin covers writer", adminSet, writerSet, ""},
{"writer does not cover admin", writerSet, adminSet, role.KeyContentDelete},
{"own covers own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=own"), ""},
{"own does not cover all", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish=all"), role.KeyContentPublish},
{"none does not cover own", unlockedGrantSet("content.read=all"), unlockedGrantSet("content.delete=own"), role.KeyContentDelete},
{"denying never escalates", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=none"), ""},
{"typed all above wildcard own", unlockedGrantSet("content.publish=own"), unlockedGrantSet("content.publish:events=all"), "content.publish:events"},
{"typed own under wildcard own", unlockedGrantSet("content.update=own"), unlockedGrantSet("content.update:events=own"), ""},
{"wildcard all would raise article", unlockedGrantSet("content.update=all", "content.update:article=none"), unlockedGrantSet("content.update=all"), "content.update:article"},
{"typed-only reader does not cover wildcard read", unlockedGrantSet("content.read:events=all"), unlockedGrantSet("content.read=all"), role.KeyContentRead},
{"toggle missing", unlockedGrantSet("content.read=all"), unlockedGrantSet("schema.edit=all"), role.KeySchemaEdit},
{"first violation follows the catalogue order", unlockedGrantSet(), unlockedGrantSet("content.delete=all", "content.read=all"), role.KeyContentRead},
{"wildcard is checked before its typed keys", unlockedGrantSet(), unlockedGrantSet("content.read:events=all", "content.read=all"), role.KeyContentRead},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.caller.Covers(c.candidate, typeKeys)
if c.wantKey == "" {
if err != nil {
t.Fatalf("expected the grants to be covered, got %v", err)
}
return
}
if got := escalationKeyOf(t, err); got != c.wantKey {
t.Fatalf("expected the key %q, got %q", c.wantKey, got)
}
})
}
t.Run("a set always covers itself", func(t *testing.T) {
for _, set := range []role.GrantSet{ownerSet, adminSet, writerSet, unlockedGrantSet(), unlockedGrantSet("content.update=all", "content.update:article=none", "content.delete:events=none")} {
if err := set.Covers(set, typeKeys); err != nil {
t.Fatalf("expected a set to cover itself, got %v", err)
}
}
})
}
func TestGrantSetEffectiveGrants(t *testing.T) {
t.Run("the events editor lists typed entries after the wildcard in catalogue then type order", func(t *testing.T) {
got := role.Role{Grants: grantRows("content.delete:events=none", "content.update:venues=all", "content.read=all", "content.update:events=own")}.EffectiveGrants()
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("duplicates resolve to the most restrictive scope and unknown keys are dropped", func(t *testing.T) {
got := role.Role{Grants: grantRows("content.update=all", "content.update=own", "content.explode=all", "content.update:Bad Key=all")}.EffectiveGrants()
want := grantRows("content.update=own")
if !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("owner-only keys are dropped on an unlocked role and kept on a locked one", func(t *testing.T) {
grants := grantRows("system.apply_updates=all", "content.read=all")
unlocked := role.Role{Grants: grants}.EffectiveGrants()
if !reflect.DeepEqual(unlocked, grantRows("content.read=all")) {
t.Fatalf("unexpected unlocked grants %v", unlocked)
}
locked := role.Role{Locked: true, Grants: grants}.EffectiveGrants()
if !reflect.DeepEqual(locked, grantRows("content.read=all", "system.apply_updates=all")) {
t.Fatalf("unexpected locked grants %v", locked)
}
})
t.Run("a role with nothing granted lists an empty slice, never nil", func(t *testing.T) {
if got := (role.Role{}).EffectiveGrants(); got == nil || len(got) != 0 {
t.Fatalf("expected an empty non-nil slice, got %#v", got)
}
})
t.Run("wildcard entries without effect are not listed", func(t *testing.T) {
if got := (role.Role{Grants: grantRows("content.create=own", "content.read=none")}).EffectiveGrants(); len(got) != 0 {
t.Fatalf("expected nothing, got %v", got)
}
})
t.Run("the effective list is valid as the grants of a write", func(t *testing.T) {
listed := role.Role{Grants: grantRows("content.update=own", "content.update=all", "content.delete:events=none", "media.view=all")}.EffectiveGrants()
if err := role.ValidateGrants(false, listed); err != nil {
t.Fatalf("expected the list to validate, got %v", err)
}
})
}
func TestDiffGrants(t *testing.T) {
t.Run("the example edit gives added, removed and changed", func(t *testing.T) {
before := grantRows("content.read=all", "content.update=own", "media.view=all")
after := grantRows("content.read=all", "content.update=all", "content.delete=own")
diff := role.DiffGrants(before, after)
if !reflect.DeepEqual(diff.Added, grantRows("content.delete=own")) {
t.Errorf("unexpected added %v", diff.Added)
}
if !reflect.DeepEqual(diff.Removed, grantRows("media.view=all")) {
t.Errorf("unexpected removed %v", diff.Removed)
}
if !reflect.DeepEqual(diff.Changed, []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) {
t.Errorf("unexpected changed %v", diff.Changed)
}
})
t.Run("an absent typed entry against a typed none is an addition", func(t *testing.T) {
diff := role.DiffGrants(nil, grantRows("content.delete:events=none"))
if len(diff.Added) != 1 || len(diff.Removed) != 0 || len(diff.Changed) != 0 {
t.Fatalf("unexpected diff %+v", diff)
}
})
t.Run("an unchanged list gives empty non-nil slices", func(t *testing.T) {
diff := role.DiffGrants(grantRows("content.read=all"), grantRows("content.read=all"))
if diff.Added == nil || diff.Removed == nil || diff.Changed == nil || !diff.Empty() {
t.Fatalf("expected empty non-nil slices, got %+v", diff)
}
})
}
func TestRoleWriteRules(t *testing.T) {
t.Run("the new sentinels are distinct and match with errors.Is", func(t *testing.T) {
sentinels := []error{role.ErrRoleHasMembers, role.ErrRoleNameTaken, role.ErrRoleLocked, role.ErrBuiltInRole, role.ErrBuiltInName, role.ErrOwnRole, role.ErrInvalidRoleName, role.ErrUnknownContentType, role.ErrNotBuiltIn, role.ErrInvalidReassign, role.ErrEscalation}
for index, sentinel := range sentinels {
for other, candidate := range sentinels {
if (index == other) != errors.Is(sentinel, candidate) {
t.Fatalf("sentinels %d and %d are not distinct", index, other)
}
}
}
})
t.Run("a name is trimmed and a blank one is refused", func(t *testing.T) {
name, err := role.NormalizeName(" Reviewer ")
if err != nil || name != "Reviewer" {
t.Fatalf("expected Reviewer, got %q %v", name, err)
}
if _, err := role.NormalizeName(" "); !errors.Is(err, role.ErrInvalidRoleName) {
t.Fatalf("expected ErrInvalidRoleName, got %v", err)
}
})
t.Run("names collide case-insensitively", func(t *testing.T) {
if !role.SameName(" contributor ", "Contributor") || role.SameName("Intern", "Contributor") {
t.Fatal("unexpected name comparison")
}
})
t.Run("typed keys must belong to an allowed type", func(t *testing.T) {
allowed := []string{"article", "events"}
if err := role.ValidateGrantTypes(grantRows("content.update:events=all", "content.read=all"), allowed); err != nil {
t.Fatalf("expected valid types, got %v", err)
}
for _, key := range []string{"content.update:nope=all", "content.update:author=all"} {
if err := role.ValidateGrantTypes(grantRows(key), allowed); !errors.Is(err, role.ErrUnknownContentType) {
t.Fatalf("%s: expected ErrUnknownContentType, got %v", key, err)
}
}
})
t.Run("the typed keys of a content type are the five per-type permissions", func(t *testing.T) {
want := []string{"content.read:events", "content.create:events", "content.update:events", "content.delete:events", "content.publish:events"}
if got := role.TypedKeys("events"); !reflect.DeepEqual(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
})
t.Run("the repository no longer lists grants on their own", func(t *testing.T) {
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName("ListGrants"); present {
t.Fatal("role.Repository must not have ListGrants")
}
for _, method := range []string{"List", "Get", "GetByName", "Create", "Save", "Delete", "ReassignMembers", "RemoveTypedGrants"} {
if _, present := reflect.TypeOf((*role.Repository)(nil)).Elem().MethodByName(method); !present {
t.Fatalf("role.Repository lacks %s", method)
}
}
})
}
func TestRoleActivityVocabulary(t *testing.T) {
t.Run("the roles category, target and actions exist", func(t *testing.T) {
if audit.CategoryRoles != "roles" || audit.TargetRole != "role" {
t.Fatalf("unexpected category %q or target %q", audit.CategoryRoles, audit.TargetRole)
}
actions := []string{audit.ActionRoleCreate, audit.ActionRoleEdit, audit.ActionRoleDelete, audit.ActionRoleReassign}
if !reflect.DeepEqual(actions, []string{"role.create", "role.edit", "role.delete", "role.reassign"}) {
t.Fatalf("unexpected actions %v", actions)
}
})
t.Run("an event in the roles category is valid and the filter accepts it", func(t *testing.T) {
event := audit.NewEvent(audit.ActionRoleCreate, audit.CategoryRoles)
event.Source = audit.SourceWeb
event.Actor = audit.AnonymousActor("x")
if err := event.Validate(); err != nil {
t.Fatalf("expected a valid event, got %v", err)
}
if category, err := audit.ParseCategory("roles"); err != nil || category != audit.CategoryRoles {
t.Fatalf("expected the roles filter, got %q %v", category, err)
}
})
}
+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())
}
}
+560
View File
@@ -0,0 +1,560 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"strings"
"testing"
)
type rolesAPIBody struct {
ID uint32 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
Version uint32 `json:"version"`
Grants []role.Grant `json:"grants"`
Members int `json:"members"`
UpdatedAt *string `json:"updated_at"`
UpdatedBy *struct {
ID uint32 `json:"id"`
Name string `json:"name"`
} `json:"updated_by"`
}
func decodeRolesAPIBody(t *testing.T, rec *httptest.ResponseRecorder, status int) rolesAPIBody {
t.Helper()
if rec.Code != status {
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
}
var body rolesAPIBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return body
}
func assertRolesAPIResult(t *testing.T, rec *httptest.ResponseRecorder, want string) {
t.Helper()
switch {
case strings.HasPrefix(want, "gate:"):
assertScopeWant(t, rec, refusedWant(strings.TrimPrefix(want, "gate:")))
case strings.HasPrefix(want, "escalation"):
assertEscalation(t, rec, strings.TrimPrefix(strings.TrimPrefix(want, "escalation"), ":"))
case strings.HasPrefix(want, "message:"):
parts := strings.SplitN(strings.TrimPrefix(want, "message:"), ":", 2)
status, _ := strconv.Atoi(parts[0])
assertAPIKeyMessage(t, rec, status, parts[1])
case strings.Contains(want, " "):
parts := strings.SplitN(want, " ", 2)
status, _ := strconv.Atoi(parts[0])
assertRefusal(t, rec, status, parts[1])
default:
status, _ := strconv.Atoi(want)
if rec.Code != status {
t.Fatalf("expected %d, got %d %q", status, rec.Code, rec.Body.String())
}
}
}
func TestRolesAPIGate(t *testing.T) {
rows := []struct {
name string
caller uint32
method string
path string
body string
want string
}{
{"Owner lists", raOwner, http.MethodGet, "/v1/role", "", "200"},
{"Admin lists", raAdmin, http.MethodGet, "/v1/role", "", "200"},
{"Viewer lists", raViewer, http.MethodGet, "/v1/role", "", "200"},
{"Viewer reads the Writer", raViewer, http.MethodGet, "/v1/role/3", "", "200"},
{"Writer lists", raWriter, http.MethodGet, "/v1/role", "", "gate:roles.view"},
{"Writer reads a malformed id", raWriter, http.MethodGet, "/v1/role/abc", "", "gate:roles.view"},
{"Viewer creates", raViewer, http.MethodPost, "/v1/role", `{"name": "X"}`, "gate:roles.manage"},
{"Viewer edits before any lookup", raViewer, http.MethodPut, "/v1/role/999", "", "gate:roles.manage"},
{"Viewer deletes", raViewer, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
{"Viewer reassigns", raViewer, http.MethodPost, "/v1/role/999/reassign", "", "gate:roles.manage"},
{"Viewer resets", raViewer, http.MethodPost, "/v1/role/3/reset", `{"version": 1}`, "gate:roles.manage"},
{"Writer edits a malformed id", raWriter, http.MethodPut, "/v1/role/abc", "", "gate:roles.manage"},
{"Writer deletes an unknown id", raWriter, http.MethodDelete, "/v1/role/999", "", "gate:roles.manage"},
{"no cookie", 0, http.MethodGet, "/v1/role", "", "message:401:" + apperr.UnauthorizedErrorMessage},
{"unverified Admin", raUnverified, http.MethodGet, "/v1/role", "", "message:403:" + apperr.VerificationRequiredErrorMessage},
}
for _, c := range rows {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path, c.body), c.want)
if c.method != http.MethodGet && len(world.recorder.events) != 0 {
t.Fatal("a refused call records nothing")
}
})
}
t.Run("API key created by user 5 reads", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raAdmin, "ci")
assertRolesAPIResult(t, world.withKey(t, http.MethodGet, "/v1/role", "", raw), "200")
})
t.Run("API key created by user 5 writes nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raAdmin, "ci")
before := len(world.roles.roles)
for _, call := range []struct{ method, path, body string }{
{http.MethodPost, "/v1/role", `{"name": "Keyed"}`},
{http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "x", 1, rolesAPIContributorRows()...)},
{http.MethodDelete, world.path("Intern"), ""},
{http.MethodPost, world.path("Contributor") + "/reassign", `{"target_role_id": 3}`},
{http.MethodPost, world.path("Writer") + "/reset", `{"version": 1}`},
} {
assertRolesAPIResult(t, world.withKey(t, call.method, call.path, call.body, raw), "message:403:"+apperr.SessionRequiredErrorMessage)
}
if len(world.roles.roles) != before || len(world.recorder.events) != 0 {
t.Fatal("nothing may be stored or recorded")
}
})
t.Run("stale token_version", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.send(t, apiKeyRequest{method: http.MethodGet, path: "/v1/role", cookieUser: raAdmin, cookieVersion: 1})
assertAPIKeyStatus(t, rec, http.StatusUnauthorized)
})
t.Run("a session follows the role live", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "gate:roles.manage")
viewer := world.role("Viewer")
viewer.Grants = append(viewer.Grants, role.Grant{Key: role.KeyRolesManage, Scope: role.ScopeAll})
world.roles.roles[viewer.ID] = viewer
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodPost, "/v1/role", `{"name": "Live"}`), "201")
})
t.Run("reading one role and /auth/me never List", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), "200")
if world.roles.calls["List"] != 0 {
t.Errorf("reading one role must not List, got %d", world.roles.calls["List"])
}
world.resetCounters()
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/auth/me", ""), "200")
if world.roles.calls["List"] != 0 {
t.Errorf("/auth/me must not List, got %d", world.roles.calls["List"])
}
})
}
func TestRolesAPIReading(t *testing.T) {
t.Run("The list returns every role with grants and member counts", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Header().Get("Content-Type"), "application/json") {
t.Fatalf("unexpected %d %q", rec.Code, rec.Header().Get("Content-Type"))
}
var listed []rolesAPIBody
if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil {
t.Fatal(err)
}
members := map[string]int{}
for index, item := range listed {
members[item.Name] = item.Members
if index > 0 && listed[index-1].ID >= item.ID {
t.Fatal("the list must be ordered by id")
}
}
if listed[0].Name != "Owner" || listed[1].Name != "Admin" || listed[2].Name != "Writer" || len(listed[0].Grants) != 34 || len(listed[1].Grants) != 29 {
t.Fatalf("unexpected built-ins %+v", listed[:3])
}
for name, count := range map[string]int{"Owner": 1, "Admin": 3, "Writer": 1, "Contributor": 4, "Intern": 0} {
if members[name] != count {
t.Errorf("%s: expected %d members, got %d", name, count, members[name])
}
}
if !strings.Contains(rec.Body.String(), `"updated_by":null`) {
t.Error("a never edited role has a null updated_by")
}
})
t.Run("Reading one role", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.Members != 4 || body.Version != 1 || !reflect.DeepEqual(body.Grants, grantRows(rolesAPIContributorRows()...)) {
t.Fatalf("unexpected role %+v", body)
}
assertRolesAPIResult(t, world.do(t, raViewer, http.MethodGet, "/v1/role/999", ""), "404 role_not_found")
assertAPIKeyMessage(t, world.do(t, raViewer, http.MethodGet, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
t.Run("A role with a hand-placed owner-only key or duplicate rows is returned normalised", func(t *testing.T) {
world := newRolesAPIWorld(t)
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "system.apply_updates=all")})
body := decodeRolesAPIBody(t, world.do(t, raAdmin, http.MethodGet, "/v1/role/"+strconv.Itoa(int(hand.ID)), ""), http.StatusOK)
if !reflect.DeepEqual(body.Grants, grantRows("content.update=own")) {
t.Fatalf("unexpected grants %v", body.Grants)
}
})
t.Run("Who saved last", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, rolesAPIContributorRows()...)), "200")
body := decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.UpdatedAt == nil || *body.UpdatedAt != "2026-10-04T12:00:00Z" || body.UpdatedBy == nil || body.UpdatedBy.ID != raAdmin || body.UpdatedBy.Name != world.users.users[raAdmin].Username {
t.Fatalf("unexpected editor %+v at %v", body.UpdatedBy, body.UpdatedAt)
}
delete(world.users.users, raAdmin)
body = decodeRolesAPIBody(t, world.do(t, raViewer, http.MethodGet, world.path("Contributor"), ""), http.StatusOK)
if body.UpdatedBy != nil {
t.Fatal("a deleted editor reads as null")
}
})
}
func TestRolesAPICreate(t *testing.T) {
t.Run("Creating the Contributor role (design flow, section 11)", func(t *testing.T) {
world := newRolesAPIWorld(t)
request := `{"name": "Writer plus", "description": "Writes posts and edits only their own", "built_in": true, "locked": true, "grants": [{"key": "content.read", "scope": "all"}, {"key": "content.create", "scope": "all"}, {"key": "content.update", "scope": "own"}, {"key": "schema.view", "scope": "all"}, {"key": "media.view", "scope": "all"}]}`
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
body := decodeRolesAPIBody(t, rec, http.StatusCreated)
if body.BuiltIn || body.Locked || body.Version != 1 || body.Members != 0 || body.Name != "Writer plus" {
t.Fatalf("unexpected role %+v", body)
}
if !reflect.DeepEqual(body.Grants, grantRows("content.read=all", "content.create=all", "content.update=own", "schema.view=all", "media.view=all")) {
t.Fatalf("unexpected grants %v", body.Grants)
}
listed := world.do(t, raAdmin, http.MethodGet, "/v1/role", "")
if !strings.Contains(listed.Body.String(), `"name":"Writer plus"`) {
t.Fatal("the new role must be listed")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Action != "role.create" {
t.Fatalf("expected exactly one role.create event, got %v", world.recorder.actions())
}
})
t.Run("A new role may start with nothing granted", func(t *testing.T) {
world := newRolesAPIWorld(t)
for _, request := range []string{`{"name": "Empty"}`, `{"name": "Empty 2", "grants": []}`} {
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", request)
if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"grants":[]`) {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
}
})
validation := []struct {
grants string
want string
}{
{"content.update=all", "201"},
{"content.update:events=own", "201"},
{"content.update:article=none", "201"},
{"content.update:events=all,content.update=own", "201"},
{"content.explode=all", "400 unknown_permission"},
{"schema.view:events=all", "400 unknown_permission"},
{"content.update:Bad Key=all", "400 unknown_permission"},
{"content.update:nope=all", "400 unknown_content_type"},
{"content.update:author=all", "400 unknown_content_type"},
{"content.update=none", "400 invalid_scope"},
{"schema.view=own", "400 invalid_scope"},
{"content.update=banana", "400 invalid_scope"},
{"content.create:events=own", "400 invalid_scope"},
{"settings.delete_site=all", "400 owner_only_permission"},
{"system.apply_updates=all", "400 owner_only_permission"},
{"content.update=all,content.update=own", "400 duplicate_permission"},
{"content.update:events=all,content.update:events=own", "400 duplicate_permission"},
}
for _, c := range validation {
t.Run("Write-time validation of "+c.grants, func(t *testing.T) {
world := newRolesAPIWorld(t)
before := len(world.roles.roles)
request := `{"name": "Probe", "grants": ` + rolesAPIGrantsJSON(strings.Split(c.grants, ",")...) + `}`
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", request), c.want)
if c.want != "201" && (len(world.roles.roles) != before || len(world.recorder.events) != 0) {
t.Fatal("nothing is stored and no audit event is recorded")
}
})
}
t.Run("owner-only keys are refused on a built-in edit too", func(t *testing.T) {
world := newRolesAPIWorld(t)
body := rolesAPIUpdateBody("Admin", "", 1, append(adminRows(), "system.apply_updates=all")...)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Admin"), body), "400 owner_only_permission")
})
longName := strings.Repeat("n", 101)
names := []struct {
name string
request string
want string
storedName string
description string
}{
{"an empty name", `{"name": ""}`, "400", "", ""},
{"a blank name", `{"name": " "}`, "400 invalid_role_name", "", ""},
{"101 characters", `{"name": "` + longName + `"}`, "400", "", ""},
{"100 characters", `{"name": "` + longName[:100] + `"}`, "201", longName[:100], ""},
{"surrounding spaces", `{"name": " Reviewer "}`, "201", "Reviewer", ""},
{"256 characters of description", `{"name": "Reviewer 2", "description": "` + strings.Repeat("d", 256) + `"}`, "400", "", ""},
{"Writer", `{"name": "Writer"}`, "409 role_name_taken", "", ""},
{"WRITER", `{"name": "WRITER"}`, "409 role_name_taken", "", ""},
{"padded contributor", `{"name": " contributor "}`, "409 role_name_taken", "", ""},
}
for _, c := range names {
t.Run("Name and description rules: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raAdmin, http.MethodPost, "/v1/role", c.request)
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.want == "201" && decodeRolesAPIBody(t, rec, http.StatusCreated).Name != c.storedName {
t.Fatalf("expected the name %q", c.storedName)
}
})
}
t.Run("a malformed body is the body error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": `), http.StatusBadRequest, apperr.BodyErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), `[`), http.StatusBadRequest, apperr.BodyErrorMessage)
})
t.Run("a grant without a key fails the validate tag", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyStatus(t, world.do(t, raAdmin, http.MethodPost, "/v1/role", `{"name": "X", "grants": [{"scope": "all"}]}`), http.StatusBadRequest)
})
t.Run("No-escalation on create", func(t *testing.T) {
world := newRolesAPIWorld(t)
request := func(rows ...string) string {
return `{"name": "Escalating", "grants": ` + rolesAPIGrantsJSON(rows...) + `}`
}
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=all")), "escalation:content.publish")
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish:events=all")), "escalation:content.publish:events")
assertRolesAPIResult(t, world.do(t, raRoleManager, http.MethodPost, "/v1/role", request("content.publish=own")), "201")
if len(world.recorder.events) != 1 {
t.Fatalf("only the successful create is recorded, got %v", world.recorder.actions())
}
})
}
func TestRolesAPIEditing(t *testing.T) {
contributor := rolesAPIContributorRows
cases := []struct {
name string
caller uint32
target string
body func(*rolesAPIWorld) string
want string
after func(*testing.T, *rolesAPIWorld, rolesAPIBody)
}{
{"grants plus delete own", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=own")...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 2 || b.Members != 4 || !reflect.DeepEqual(b.Grants, grantRows(contributor("content.delete=own")...)) {
t.Fatalf("unexpected role %+v", b)
}
}},
{"grants minus update", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, "content.read=all", "content.create=all")
}, "200", nil},
{"nothing changed", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, contributor()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 1 || len(w.recorder.events) != 0 {
t.Fatalf("a no-op must not bump the version or record, got %+v", b)
}
}},
{"nothing changed on a stale version", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 7, contributor()...)
}, "409 role_conflict", nil},
{"no version field", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return `{"name": "Contributor", "grants": []}`
}, "400", nil},
{"name taken", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("writer", "", 1, contributor()...)
}, "409 role_name_taken", nil},
{"case-only rename", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("contributor", "", 1, contributor()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Name != "contributor" {
t.Fatalf("expected the name as sent, got %q", b.Name)
}
}},
{"unknown key", raAdmin, "Contributor", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all")
}, "400 unknown_permission", nil},
{"unknown role", raAdmin, "Missing", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("X", "", 1)
}, "404 role_not_found", nil},
{"owner edits the writer description", raOwner, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
}, "200", func(t *testing.T, w *rolesAPIWorld, b rolesAPIBody) {
if b.Version != 2 || !b.BuiltIn {
t.Fatalf("the role keeps built_in, got %+v", b)
}
}},
{"owner renames the writer", raOwner, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Author", "", 1, writerRows()...)
}, "400 built_in_name", nil},
{"admin edits the writer", raAdmin, "Writer", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Writer", "d", 1, writerRows()...)
}, "gate:roles.edit_owner_only", nil},
{"admin edits admin", raAdmin, "Admin", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Admin", "d", 1, adminRows()...)
}, "gate:roles.edit_owner_only", nil},
{"owner edits the owner", raOwner, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "", 1) }, "403 role_locked", nil},
{"admin edits the owner", raAdmin, "Owner", func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Owner", "x", 1) }, "403 role_locked", nil},
{"role manager edits their own role", raRoleManager, "Role manager", func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Role manager", "d", 1)
}, "403 own_role", nil},
}
for _, c := range cases {
t.Run("Editing a role: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPut, world.path(c.target), c.body(world))
if c.want == "400" {
assertAPIKeyStatus(t, rec, http.StatusBadRequest)
return
}
assertRolesAPIResult(t, rec, c.want)
if c.after != nil {
c.after(t, world, decodeRolesAPIBody(t, rec, http.StatusOK))
}
if c.want != "200" && c.target != "Missing" && world.role(c.target).Version != 1 {
t.Fatal("a refusal bumps no version")
}
})
}
t.Run("Editing a role: a refused call with a stale version still reports the escalation first", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Content lead"), rolesAPIUpdateBody("Content lead", "", 9))
assertRolesAPIResult(t, rec, "escalation")
})
t.Run("Order: an unknown key is a 400 before the escalation", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raRoleManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, "content.explode=all", "content.delete=all"))
assertRolesAPIResult(t, rec, "400 unknown_permission")
})
t.Run("Members see the new grants on their next request, with the same cookie and API key", func(t *testing.T) {
world := newRolesAPIWorld(t)
raw, _ := world.issueKey(raContributorOne, "contributor key")
world.seedArticle(1, true)
assertAPIKeyStatus(t, world.do(t, raContributorOne, http.MethodDelete, "/v1/article/1", ""), http.StatusForbidden)
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor("content.delete=all")...)), "200")
assertAPIKeyStatus(t, world.withKey(t, http.MethodDelete, "/v1/article/1", "", raw), http.StatusNoContent)
})
t.Run("A stale editor gets a conflict, then reads the new state", func(t *testing.T) {
world := newRolesAPIWorld(t)
first := rolesAPIUpdateBody("Contributor", "from 3", 1, contributor()...)
second := rolesAPIUpdateBody("Contributor", "from 5", 1, contributor()...)
assertRolesAPIResult(t, world.do(t, raOwner, http.MethodPut, world.path("Contributor"), first), "200")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), second), "409 role_conflict")
if stored := world.role("Contributor"); stored.Description != "from 3" || stored.Version != 2 {
t.Fatalf("the stored role must be the first save, got %+v", stored)
}
})
t.Run("A save that loses the race at the repository answers role_conflict", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.beforeSave = func() {
stored := world.role("Contributor")
stored.Version = 2
world.roles.roles[stored.ID] = stored
}
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "409 role_conflict")
})
t.Run("A storage failure at the save is a 500", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Save")
assertRolesAPIResult(t, world.do(t, raAdmin, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "d", 1, contributor()...)), "500 Internal server error")
})
t.Run("A non-numeric id is the id error", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPut, "/v1/role/abc", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodDelete, "/v1/role/abc", ""), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raAdmin, http.MethodPost, "/v1/role/abc/reassign", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
assertAPIKeyMessage(t, world.do(t, raOwner, http.MethodPost, "/v1/role/abc/reset", "{}"), http.StatusBadRequest, apperr.IdUint32ErrorMessage)
})
noEscalation := []struct {
caller uint32
extra []string
want string
}{
{raRoleManager, []string{"content.publish=own"}, "200"},
{raRoleManager, []string{"content.publish=all"}, "escalation:content.publish"},
{raRoleManager, []string{"content.delete=own"}, "escalation:content.delete"},
{raRoleManager, []string{"schema.edit=all"}, "escalation"},
{raRoleManager, []string{"content.publish:events=none"}, "200"},
{raRoleManager, []string{"content.publish:events=all"}, "escalation:content.publish:events"},
{raRoleManager, []string{"content.update:events=own"}, "200"},
{raRoleManager, []string{"content.update:events=all"}, "escalation"},
{raAdmin, []string{"content.delete=all"}, "200"},
{raOwner, []string{"content.delete=all"}, "200"},
}
for _, c := range noEscalation {
t.Run("No-escalation on edit: "+c.extra[0]+" by user "+strconv.Itoa(int(c.caller)), func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, c.caller, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor(c.extra...)...))
assertRolesAPIResult(t, rec, c.want)
if c.want != "200" && (world.role("Contributor").Version != 1 || len(world.recorder.events) != 0) {
t.Fatal("a refused call changes no field, bumps no version and records no event")
}
})
}
t.Run("No-escalation with a typed-only reader and a typed none", func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, raNarrowManager, http.MethodPut, world.path("Contributor"), rolesAPIUpdateBody("Contributor", "", 1, contributor()...)), "escalation")
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all")), "escalation:content.update:article")
assertRolesAPIResult(t, world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=none")), "200")
})
t.Run("No-escalation with a typed manager: a typed own above the caller's none is refused", func(t *testing.T) {
world := newRolesAPIWorld(t)
rec := world.do(t, raTypedManager, http.MethodPut, world.path("Typed target"), rolesAPIUpdateBody("Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=own"))
assertRolesAPIResult(t, rec, "escalation:content.update:article")
if world.role("Typed target").Version != 1 || len(world.recorder.events) != 0 {
t.Fatal("a refused call changes no field, bumps no version and records no event")
}
})
above := []struct {
name string
caller uint32
method string
path func(*rolesAPIWorld) string
body func(*rolesAPIWorld) string
want string
}{
{"PUT Content lead by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Content lead", "", 1) }, "escalation"},
{"DELETE Content lead by Role manager", raRoleManager, http.MethodDelete, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string { return "" }, "escalation"},
{"reassign Content lead by Role manager", raRoleManager, http.MethodPost, func(w *rolesAPIWorld) string { return w.path("Content lead") + "/reassign" }, func(w *rolesAPIWorld) string { return `{"target_role_id": ` + strconv.Itoa(int(w.ids["Intern"])) + `}` }, "escalation"},
{"PUT Contributor with a harmless change by Role manager", raRoleManager, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Contributor") }, func(*rolesAPIWorld) string { return rolesAPIUpdateBody("Contributor", "harmless", 1, contributor()...) }, "200"},
{"PUT Content lead with a harmless change by Admin", raAdmin, http.MethodPut, func(w *rolesAPIWorld) string { return w.path("Content lead") }, func(*rolesAPIWorld) string {
return rolesAPIUpdateBody("Content lead", "harmless", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all")
}, "200"},
}
for _, c := range above {
t.Run("A caller cannot touch a role above their own grants: "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
assertRolesAPIResult(t, world.do(t, c.caller, c.method, c.path(world), c.body(world)), c.want)
})
}
}
+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")
}
})
}
+95
View File
@@ -0,0 +1,95 @@
package test
import (
"RenewCMS/internal/domain/role"
"go/ast"
"go/token"
"path/filepath"
"strconv"
"strings"
"testing"
)
func rolesAPIRoleUseCaseFiles() []string {
return []string{
"../internal/infrastructure/useCases/roleSupport.go",
"../internal/infrastructure/useCases/roleView.go",
"../internal/infrastructure/useCases/ListRoles.go",
"../internal/infrastructure/useCases/GetRole.go",
"../internal/infrastructure/useCases/CreateRole.go",
"../internal/infrastructure/useCases/UpdateRole.go",
"../internal/infrastructure/useCases/DeleteRole.go",
"../internal/infrastructure/useCases/ReassignRoleMembers.go",
}
}
func TestRolesAPIHygiene(t *testing.T) {
t.Run("The comparison logic lives once, in the domain", func(t *testing.T) {
files := append(rolesAPIRoleUseCaseFiles(), nonTestGoFiles(t, "../api/handlers/role", "../api/middleware")...)
for _, path := range files {
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
selector, ok := node.(*ast.SelectorExpr)
if ok && (selector.Sel.Name == "ScopeAll" || selector.Sel.Name == "ScopeOwn" || selector.Sel.Name == "ScopeNone") {
t.Errorf("%s reads the scope %s", path, selector.Sel.Name)
}
return true
})
}
})
t.Run("api/router.go and the role handler hold no catalogue key literal", func(t *testing.T) {
keys := map[string]bool{}
for _, permission := range role.Catalogue() {
keys[permission.Key] = true
}
files := append(nonTestGoFiles(t, "../api/handlers/role"), "../api/router.go")
for _, path := range files {
ast.Inspect(parsedGoFile(t, path), func(node ast.Node) bool {
literal, ok := node.(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
return true
}
value, err := strconv.Unquote(literal.Value)
if err == nil && keys[value] {
t.Errorf("%s holds the literal %q", path, value)
}
return true
})
}
})
t.Run("no handler under api/handlers/role imports persistence, mailer, models or gorm", func(t *testing.T) {
files, _ := filepath.Glob("../api/handlers/role/*.go")
if len(files) == 0 {
t.Fatal("no role handler")
}
for _, file := range files {
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(file), "../")) {
for _, forbidden := range []string{"persistence", "mailer", "models", "gorm"} {
if strings.Contains(imported, forbidden) {
t.Errorf("%s imports %s", file, imported)
}
}
}
}
})
t.Run("the role use cases import only domain packages", func(t *testing.T) {
for _, path := range rolesAPIRoleUseCaseFiles() {
for _, imported := range importsOf(t, strings.TrimPrefix(filepath.ToSlash(path), "../")) {
if strings.HasPrefix(imported, "RenewCMS/") && !strings.HasPrefix(imported, "RenewCMS/internal/domain/") {
t.Errorf("%s imports %s", path, imported)
}
}
}
})
t.Run("the router mounts every role route behind the keys", func(t *testing.T) {
source := readTestFile(t, "../api/router.go")
for _, wanted := range []string{"role.KeyRolesView", "role.KeyRolesManage", "NewRoleRouter"} {
if !strings.Contains(source, wanted) {
t.Errorf("api/router.go lacks %s", wanted)
}
}
})
}
+252
View File
@@ -0,0 +1,252 @@
package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"reflect"
"sync"
"testing"
"time"
)
func rolesAPIGrantKeys(grants []role.Grant) []string {
keys := []string{}
for _, grant := range grants {
keys = append(keys, grant.Key+"="+string(grant.Scope))
}
return keys
}
func rolesAPICreateRole(t *testing.T, repo *persistence.RoleRepository, name string, rows ...string) role.Role {
t.Helper()
created, err := repo.Create(role.Role{Name: name, Version: 1, Grants: grantRows(rows...)})
if err != nil {
t.Fatal(err)
}
return created
}
func TestRoleRepositoryRolesAPI(t *testing.T) {
t.Run("List returns the grants of every role in one pass", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
rolesAPICreateRole(t, repo, "Contributor", "content.read=all", "content.update:events=own")
listed, err := repo.List()
if err != nil {
t.Fatal(err)
}
if len(listed) != 4 {
t.Fatalf("expected 4 roles, got %d", len(listed))
}
for index, expected := range []int{34, 29, 9, 2} {
if len(listed[index].Grants) != expected {
t.Errorf("role %s: expected %d grants, got %d", listed[index].Name, expected, len(listed[index].Grants))
}
}
})
t.Run("seeded roles have no editor and no edit time", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
listed, _ := repo.List()
for _, seeded := range listed {
if seeded.UpdatedBy != nil || !seeded.UpdatedAt.IsZero() {
t.Errorf("role %s: unexpected editor %v at %v", seeded.Name, seeded.UpdatedBy, seeded.UpdatedAt)
}
}
})
t.Run("Save writes the editor and the time and bumps the version", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
editor := uint32(5)
edited := created
edited.Description = "changed"
edited.UpdatedAt = time.Date(2026, time.October, 5, 11, 2, 0, 0, time.UTC)
edited.UpdatedBy = &editor
saved, err := repo.Save(edited)
if err != nil {
t.Fatal(err)
}
if saved.Version != 2 || saved.UpdatedBy == nil || *saved.UpdatedBy != 5 || !saved.UpdatedAt.Equal(edited.UpdatedAt) {
t.Fatalf("unexpected saved role %+v", saved)
}
})
t.Run("Save is a compare-and-swap on the version", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
first := created
first.Description = "first"
second := created
second.Description = "second"
if _, err := repo.Save(first); err != nil {
t.Fatal(err)
}
if _, err := repo.Save(second); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("expected ErrRoleConflict, got %v", err)
}
stored, _ := repo.Get(created.ID)
if stored.Description != "first" || stored.Version != 2 {
t.Fatalf("unexpected stored role %+v", stored)
}
})
t.Run("two concurrent saves of version 1 give exactly one winner", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
results := make([]error, 2)
var group sync.WaitGroup
for index := range results {
group.Add(1)
go func() {
defer group.Done()
attempt := created
attempt.Description = []string{"a", "b"}[index]
_, results[index] = repo.Save(attempt)
}()
}
group.Wait()
successes := 0
for _, err := range results {
if err == nil {
successes++
} else if !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("unexpected error %v", err)
}
}
if successes != 1 {
t.Fatalf("expected exactly one winner, got %d", successes)
}
})
t.Run("Delete removes the role and its grants", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Intern", "content.read=own")
rowsBefore := countRows(t, db, &models.RolePermission{})
if err := repo.Delete(created.ID); err != nil {
t.Fatal(err)
}
if _, err := repo.Get(created.ID); !errors.Is(err, role.ErrRoleNotFound) {
t.Fatalf("expected the role to be gone, got %v", err)
}
if countRows(t, db, &models.RolePermission{}) != rowsBefore-1 {
t.Error("the grant rows were not removed")
}
})
t.Run("Delete refuses a role that still has members", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
createUserRow(t, db, 1, "", &created.ID)
if err := repo.Delete(created.ID); !errors.Is(err, role.ErrRoleHasMembers) {
t.Fatalf("expected ErrRoleHasMembers, got %v", err)
}
if _, err := repo.Get(created.ID); err != nil {
t.Fatalf("the role must still exist: %v", err)
}
})
t.Run("Delete reports a missing role", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
if err := repo.Delete(9999); !errors.Is(err, role.ErrRoleNotFound) {
t.Fatalf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("ReassignMembers counts the moved users and touches only role_id", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
source := rolesAPICreateRole(t, repo, "Contributor", "content.read=all")
writer := storedRole(t, db, "Writer")
createUserRow(t, db, 1, "", &source.ID)
createUserRow(t, db, 2, "", &source.ID)
createUserRow(t, db, 3, "", &writer.ID)
before := userRow(t, db, 1)
moved, err := repo.ReassignMembers(source.ID, writer.ID)
if err != nil || moved != 2 {
t.Fatalf("expected 2 moved, got %d %v", moved, err)
}
after := userRow(t, db, 1)
if roleIDOf(after) != writer.ID {
t.Errorf("user 1 was not moved")
}
after.RoleID, before.RoleID = nil, nil
if !reflect.DeepEqual(before, after) {
t.Errorf("only role_id may change: %+v vs %+v", before, after)
}
if moved, _ := repo.ReassignMembers(source.ID, writer.ID); moved != 0 {
t.Errorf("a second move must move nobody, got %d", moved)
}
})
t.Run("RemoveTypedGrants removes the five typed forms of one type only and bumps affected roles once", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
affected := rolesAPICreateRole(t, repo, "Events editor", "content.read=all", "content.update:events=own", "content.delete:events=none", "content.read:events=all", "content.create:events=all", "content.publish:events=own", "content.update:venues=all")
untouched := rolesAPICreateRole(t, repo, "Venues only", "content.update:venues=all", "content.read=all")
removed, err := repo.RemoveTypedGrants("events")
if err != nil || removed != 1 {
t.Fatalf("expected 1 affected role, got %d %v", removed, err)
}
stored, _ := repo.Get(affected.ID)
if !reflect.DeepEqual(rolesAPIGrantKeys(stored.Grants), []string{"content.read=all", "content.update:venues=all"}) || stored.Version != 2 {
t.Fatalf("unexpected affected role %v version %d", rolesAPIGrantKeys(stored.Grants), stored.Version)
}
other, _ := repo.Get(untouched.ID)
if other.Version != 1 || len(other.Grants) != 2 {
t.Fatalf("the other role must be untouched: %+v", other)
}
if again, _ := repo.RemoveTypedGrants("events"); again != 0 {
t.Errorf("a second removal must affect nothing, got %d", again)
}
})
t.Run("a content type key that prefixes another is not matched", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
kept := rolesAPICreateRole(t, repo, "Long key", "content.update:events_extra=all")
if removed, _ := repo.RemoveTypedGrants("events"); removed != 0 {
t.Fatalf("expected nothing removed, got %d", removed)
}
stored, _ := repo.Get(kept.ID)
if len(stored.Grants) != 1 {
t.Fatal("the grant of events_extra must be kept")
}
})
t.Run("the new methods fail on a broken database", func(t *testing.T) {
for name, call := range map[string]func(*persistence.RoleRepository) error{
"Delete": func(r *persistence.RoleRepository) error { return r.Delete(4) },
"ReassignMembers": func(r *persistence.RoleRepository) error { _, err := r.ReassignMembers(4, 3); return err },
"RemoveTypedGrants": func(r *persistence.RoleRepository) error { _, err := r.RemoveTypedGrants("events"); return err },
} {
db := openMigratedRolesDB(t)
for _, table := range []string{"role_permissions", "users"} {
if err := db.Migrator().DropTable(table); err != nil {
t.Fatal(err)
}
}
sqlDB, _ := db.DB()
_ = sqlDB.Close()
if err := call(persistence.NewRoleRepository(db)); err == nil || errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("%s: expected a storage failure, got %v", name, err)
}
}
})
t.Run("the role columns exist after Migrate", func(t *testing.T) {
db := openMigratedRolesDB(t)
for _, column := range []string{"updated_at", "updated_by"} {
if !db.Migrator().HasColumn(&models.Role{}, column) {
t.Errorf("roles.%s is missing", column)
}
}
})
}
+887
View File
@@ -0,0 +1,887 @@
package test
import (
"RenewCMS/internal/domain/audit"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"reflect"
"strconv"
"testing"
)
func (w *rolesAPIWorld) update(caller uint32, name string, roleName string, description string, version uint32, rows ...string) (useCases.RoleView, error) {
return w.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{
Caller: w.caller(caller), ID: w.ids[roleName], Name: name, Description: description, Version: version, Grants: grantRows(rows...),
})
}
func (w *rolesAPIWorld) create(caller uint32, name string, rows ...string) (useCases.RoleView, error) {
return w.container.CreateRoleUseCase.Create(context.Background(), useCases.CreateRoleCommand{Caller: w.caller(caller), Name: name, Grants: grantRows(rows...)})
}
func (w *rolesAPIWorld) remove(caller uint32, roleName string) error {
return w.container.DeleteRoleUseCase.Delete(context.Background(), useCases.DeleteRoleCommand{Caller: w.caller(caller), ID: w.ids[roleName]})
}
func (w *rolesAPIWorld) reassign(caller uint32, source string, targetID uint32) (int, error) {
return w.container.ReassignRoleMembersUseCase.Reassign(context.Background(), useCases.ReassignRoleMembersCommand{Caller: w.caller(caller), SourceID: w.ids[source], TargetID: targetID})
}
func requireEscalationKey(t *testing.T, err error, key string) {
t.Helper()
if got := escalationKeyOf(t, err); key != "" && got != key {
t.Fatalf("expected the escalation key %q, got %q", key, got)
}
}
func TestListAndGetRolesUseCases(t *testing.T) {
t.Run("the list holds every role by id with members counted through the legacy fallback", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
listed, err := world.container.ListRolesUseCase.List()
if err != nil {
t.Fatal(err)
}
if world.roles.calls["List"] != 1 {
t.Errorf("expected one List call, got %d", world.roles.calls["List"])
}
got := map[string]int{}
for index, item := range listed {
got[item.Name] = item.Members
if index > 0 && listed[index-1].ID >= item.ID {
t.Errorf("the list must be ordered by id")
}
}
for name, members := range map[string]int{"Owner": 1, "Admin": 3, "Writer": 1, "Contributor": 4, "Intern": 0, "Content lead": 1} {
if got[name] != members {
t.Errorf("%s: expected %d members, got %d", name, members, got[name])
}
}
if len(listed[0].Grants) != 34 || len(listed[1].Grants) != 29 {
t.Errorf("expected 34 and 29 grants, got %d and %d", len(listed[0].Grants), len(listed[1].Grants))
}
})
t.Run("a hand-placed owner-only key and duplicate rows are returned normalised", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("content.update=own", "content.update=all", "system.apply_updates=all")})
found, err := world.container.GetRoleUseCase.Get(created.ID)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(found.Grants, grantRows("content.update=own")) {
t.Fatalf("unexpected grants %v", found.Grants)
}
})
t.Run("reading one role reports members, version and a missing role", func(t *testing.T) {
world := newRolesAPIWorld(t)
found, err := world.container.GetRoleUseCase.Get(world.ids["Contributor"])
if err != nil || found.Members != 4 || found.Version != 1 {
t.Fatalf("unexpected role %+v %v", found, err)
}
if _, err := world.container.GetRoleUseCase.Get(raUnknownRoleID); !errors.Is(err, role.ErrRoleNotFound) {
t.Fatalf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("updated_by names the display name, else the username, and is null for a deleted user", func(t *testing.T) {
world := newRolesAPIWorld(t)
editor := raAdmin
stored := world.role("Contributor")
stored.UpdatedAt = world.now
stored.UpdatedBy = &editor
world.roles.roles[stored.ID] = stored
found, _ := world.container.GetRoleUseCase.Get(stored.ID)
if found.UpdatedBy == nil || found.UpdatedBy.ID != raAdmin || found.UpdatedBy.Name != world.users.users[raAdmin].Username {
t.Fatalf("expected the username, got %+v", found.UpdatedBy)
}
if found.UpdatedAt == nil || !found.UpdatedAt.Equal(world.now) {
t.Fatalf("unexpected updated_at %v", found.UpdatedAt)
}
named := world.users.users[raAdmin]
named.DisplayName = " Marta "
world.users.users[raAdmin] = named
found, _ = world.container.GetRoleUseCase.Get(stored.ID)
if found.UpdatedBy.Name != "Marta" {
t.Fatalf("expected the display name, got %q", found.UpdatedBy.Name)
}
delete(world.users.users, raAdmin)
found, _ = world.container.GetRoleUseCase.Get(stored.ID)
if found.UpdatedBy != nil {
t.Fatalf("expected a null editor, got %+v", found.UpdatedBy)
}
})
t.Run("a role never edited has no editor and no time", func(t *testing.T) {
world := newRolesAPIWorld(t)
found, _ := world.container.GetRoleUseCase.Get(world.ids["Contributor"])
if found.UpdatedBy != nil || found.UpdatedAt != nil {
t.Fatalf("expected null editor and time, got %+v %v", found.UpdatedBy, found.UpdatedAt)
}
})
t.Run("a storage failure is returned", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("List")
if _, err := world.container.ListRolesUseCase.List(); err == nil {
t.Fatal("expected an error")
}
world.roles.fail.failNext("Get")
if _, err := world.container.GetRoleUseCase.Get(world.ids["Contributor"]); err == nil {
t.Fatal("expected an error")
}
})
}
func TestCreateRoleUseCase(t *testing.T) {
validation := []struct {
name string
rows []string
want error
typed bool
}{
{"content.update=all", []string{"content.update=all"}, nil, false},
{"content.update:events=own", []string{"content.update:events=own"}, nil, false},
{"content.update:article=none", []string{"content.update:article=none"}, nil, false},
{"typed plus wildcard", []string{"content.update:events=all", "content.update=own"}, nil, false},
{"content.explode=all", []string{"content.explode=all"}, role.ErrUnknownPermission, false},
{"schema.view:events=all", []string{"schema.view:events=all"}, role.ErrUnknownPermission, false},
{"content.update:Bad Key=all", []string{"content.update:Bad Key=all"}, role.ErrUnknownPermission, false},
{"content.update:nope=all", []string{"content.update:nope=all"}, role.ErrUnknownContentType, false},
{"content.update:author=all", []string{"content.update:author=all"}, role.ErrUnknownContentType, false},
{"content.update=none", []string{"content.update=none"}, role.ErrInvalidScope, false},
{"schema.view=own", []string{"schema.view=own"}, role.ErrInvalidScope, false},
{"content.update=banana", []string{"content.update=banana"}, role.ErrInvalidScope, false},
{"content.create:events=own", []string{"content.create:events=own"}, role.ErrInvalidScope, false},
{"settings.delete_site=all", []string{"settings.delete_site=all"}, role.ErrOwnerOnlyGrant, false},
{"system.apply_updates=all", []string{"system.apply_updates=all"}, role.ErrOwnerOnlyGrant, false},
{"duplicate wildcard", []string{"content.update=all", "content.update=own"}, role.ErrDuplicateGrant, false},
{"duplicate typed", []string{"content.update:events=all", "content.update:events=own"}, role.ErrDuplicateGrant, false},
}
for _, c := range validation {
t.Run("Admin creating a role with "+c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
before := len(world.roles.roles)
created, err := world.create(raAdmin, "Probe", c.rows...)
if !errors.Is(err, c.want) || (c.want == nil && err != nil) {
t.Fatalf("expected %v, got %v", c.want, err)
}
if c.want != nil {
if len(world.roles.roles) != before || len(world.recorder.events) != 0 {
t.Fatalf("a refused create must store nothing and record nothing")
}
return
}
if created.BuiltIn || created.Locked || created.Version != 1 || created.Members != 0 {
t.Fatalf("unexpected role %+v", created)
}
if len(world.recorder.events) != 1 {
t.Fatalf("expected one event, got %d", len(world.recorder.events))
}
})
}
t.Run("the grants come back in catalogue order and a client cannot set the flags", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.container.CreateRoleUseCase.Create(context.Background(), useCases.CreateRoleCommand{
Caller: world.caller(raAdmin), Name: "Writer plus", Description: "Writes", Grants: grantRows("media.view=all", "content.update=own", "content.read=all"),
})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(created.Grants, grantRows("content.read=all", "content.update=own", "media.view=all")) {
t.Fatalf("unexpected grants %v", created.Grants)
}
stored := world.roles.roles[created.ID]
if stored.BuiltIn || stored.Locked || stored.Version != 1 || stored.UpdatedBy != nil {
t.Fatalf("unexpected stored role %+v", stored)
}
})
t.Run("a new role may start with nothing granted", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.create(raAdmin, "Empty")
if err != nil || created.Grants == nil || len(created.Grants) != 0 {
t.Fatalf("expected empty grants, got %+v %v", created.Grants, err)
}
})
t.Run("names are trimmed, blank ones refused and duplicates found case-insensitively", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.create(raAdmin, " Reviewer ")
if err != nil || created.Name != "Reviewer" {
t.Fatalf("expected the trimmed name, got %q %v", created.Name, err)
}
if _, err := world.create(raAdmin, " "); !errors.Is(err, role.ErrInvalidRoleName) {
t.Fatalf("expected ErrInvalidRoleName, got %v", err)
}
for _, name := range []string{"Writer", "WRITER", " contributor ", "reviewer"} {
if _, err := world.create(raAdmin, name); !errors.Is(err, role.ErrRoleNameTaken) {
t.Errorf("%q: expected ErrRoleNameTaken, got %v", name, err)
}
}
})
t.Run("no-escalation applies to a create", func(t *testing.T) {
world := newRolesAPIWorld(t)
cases := []struct {
caller uint32
rows []string
key string
}{
{raRoleManager, []string{"content.publish=all"}, "content.publish"},
{raRoleManager, []string{"content.delete=own"}, "content.delete"},
{raRoleManager, []string{"schema.edit=all"}, "schema.edit"},
{raRoleManager, []string{"content.publish:events=all"}, "content.publish:events"},
{raTypedManager, []string{"content.update=all"}, "content.update:article"},
}
for _, c := range cases {
_, err := world.create(c.caller, "Escalating", c.rows...)
requireEscalationKey(t, err, c.key)
}
for _, c := range []struct {
caller uint32
rows []string
}{
{raRoleManager, []string{"content.publish=own"}},
{raRoleManager, []string{"content.publish:events=none"}},
{raOwner, []string{"content.delete=all"}},
} {
name := "Fine " + c.rows[0] + string(rune('a'+c.caller))
if _, err := world.create(c.caller, name, c.rows...); err != nil {
t.Errorf("%v: expected success, got %v", c.rows, err)
}
}
})
t.Run("the order of checks is grants, covers, then the name", func(t *testing.T) {
world := newRolesAPIWorld(t)
if _, err := world.create(raRoleManager, "Writer", "content.explode=all"); !errors.Is(err, role.ErrUnknownPermission) {
t.Fatalf("expected the validation first, got %v", err)
}
if _, err := world.create(raRoleManager, "Writer", "content.publish=all"); !errors.Is(err, role.ErrEscalation) {
t.Fatalf("expected the escalation before the name, got %v", err)
}
})
t.Run("the create event carries the target and every granted entry", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, _ := world.create(raAdmin, "Reviewer", "content.read=all", "content.update=own")
event := world.recorder.events[0]
if event.Action != "role.create" || event.Category != audit.CategoryRoles || event.Outcome != audit.OutcomeSuccess {
t.Fatalf("unexpected event %+v", event)
}
if event.Target.Type != "role" || event.Target.Name != "Reviewer" || event.Target.ID != strconv.FormatUint(uint64(created.ID), 10) {
t.Fatalf("unexpected target %+v", event.Target)
}
if !reflect.DeepEqual(event.Params[audit.ParamAdded], created.Grants) {
t.Fatalf("unexpected added %v", event.Params[audit.ParamAdded])
}
})
t.Run("a storage failure records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Create")
if _, err := world.create(raAdmin, "Reviewer"); err == nil {
t.Fatal("expected an error")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].FailureReason == nil {
t.Fatalf("expected one failed event, got %+v", world.recorder.events)
}
})
}
func TestUpdateRoleUseCase(t *testing.T) {
contributor := rolesAPIContributorRows
t.Run("editing a role", func(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
newName string
description string
version uint32
grants []string
wantErr error
wantVersion uint32
}{
{"grants plus delete own", raAdmin, "Contributor", "Contributor", "", 1, contributor("content.delete=own"), nil, 2},
{"grants minus update", raAdmin, "Contributor", "Contributor", "", 1, []string{"content.read=all", "content.create=all"}, nil, 2},
{"description changed", raAdmin, "Contributor", "Contributor", "new", 1, contributor(), nil, 2},
{"nothing changed", raAdmin, "Contributor", "Contributor", "", 1, contributor(), nil, 1},
{"nothing changed on a stale version", raAdmin, "Contributor", "Contributor", "", 7, contributor(), role.ErrRoleConflict, 1},
{"body changed on a stale version", raAdmin, "Contributor", "Contributor", "x", 7, contributor(), role.ErrRoleConflict, 1},
{"name taken", raAdmin, "Contributor", "writer", "", 1, contributor(), role.ErrRoleNameTaken, 1},
{"case-only rename", raAdmin, "Contributor", "contributor", "", 1, contributor(), nil, 2},
{"name of another custom role", raAdmin, "Contributor", "Intern", "", 1, contributor(), role.ErrRoleNameTaken, 1},
{"blank name", raAdmin, "Contributor", " ", "", 1, contributor(), role.ErrInvalidRoleName, 1},
{"unknown key", raAdmin, "Contributor", "Contributor", "", 1, []string{"content.explode=all"}, role.ErrUnknownPermission, 1},
{"owner edits the writer description", raOwner, "Writer", "Writer", "d", 1, writerRows(), nil, 2},
{"owner adds media.delete=own to the writer", raOwner, "Writer", "Writer", "", 1, append(writerRows(), "media.delete=own"), nil, 2},
{"owner renames a built-in", raOwner, "Writer", "Author", "", 1, writerRows(), role.ErrBuiltInName, 1},
{"owner adds an owner-only key to admin", raOwner, "Admin", "Admin", "", 1, append(adminRows(), "system.apply_updates=all"), role.ErrOwnerOnlyGrant, 1},
{"owner removes users.remove from admin", raOwner, "Admin", "Admin", "", 1, withoutRow(adminRows(), "users.remove=all"), nil, 2},
{"owner edits the owner", raOwner, "Owner", "Owner", "", 1, nil, role.ErrRoleLocked, 1},
{"admin edits the owner", raAdmin, "Owner", "Owner", "", 1, nil, role.ErrRoleLocked, 1},
{"admin edits the writer", raAdmin, "Writer", "Writer", "d", 1, writerRows(), role.ErrMissingPermission, 1},
{"admin edits their own built-in role", raAdmin, "Admin", "Admin", "d", 1, adminRows(), role.ErrMissingPermission, 1},
{"role manager edits their own role", raRoleManager, "Role manager", "Role manager", "d", 1, nil, role.ErrOwnRole, 1},
{"unknown role", raAdmin, "Missing", "X", "", 1, nil, role.ErrRoleNotFound, 1},
}
for _, c := range rows {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
updated, err := world.update(c.caller, c.newName, c.target, c.description, c.version, c.grants...)
if c.wantErr != nil {
if !errors.Is(err, c.wantErr) {
t.Fatalf("expected %v, got %v", c.wantErr, err)
}
if len(world.recorder.events) != 0 {
t.Fatalf("a refusal must record nothing")
}
if c.target != "Missing" && world.role(c.target).Version != 1 {
t.Fatalf("a refusal must not bump the version")
}
return
}
if err != nil || updated.Version != c.wantVersion {
t.Fatalf("expected version %d, got %+v %v", c.wantVersion, updated, err)
}
if updated.ID != world.ids[c.target] || updated.BuiltIn != world.role(c.target).BuiltIn || updated.Locked {
t.Fatalf("id and flags must be unchanged: %+v", updated)
}
})
}
})
t.Run("a missing-permission refusal names roles.edit_owner_only", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raAdmin, "Writer", "Writer", "d", 1, writerRows()...)
requireMissingKey(t, err, role.KeyRolesEditOwnerOnly)
})
t.Run("no-escalation on edit", func(t *testing.T) {
cases := []struct {
caller uint32
extra []string
wantKey string
}{
{raRoleManager, []string{"content.publish=own"}, ""},
{raRoleManager, []string{"content.publish=all"}, "content.publish"},
{raRoleManager, []string{"content.delete=own"}, "content.delete"},
{raRoleManager, []string{"schema.edit=all"}, "schema.edit"},
{raRoleManager, []string{"content.publish:events=none"}, ""},
{raRoleManager, []string{"content.publish:events=all"}, "content.publish:events"},
{raRoleManager, []string{"content.update:events=own"}, ""},
{raRoleManager, []string{"content.update:events=all"}, "content.update:events"},
{raAdmin, []string{"content.delete=all"}, ""},
{raOwner, []string{"content.delete=all"}, ""},
}
for _, c := range cases {
t.Run(c.extra[0]+" by "+string(rune('A'+c.caller)), func(t *testing.T) {
world := newRolesAPIWorld(t)
before := world.role("Contributor")
_, err := world.update(c.caller, "Contributor", "Contributor", "", 1, contributor(c.extra...)...)
if c.wantKey == "" {
if err != nil {
t.Fatalf("expected success, got %v", err)
}
return
}
requireEscalationKey(t, err, c.wantKey)
if !reflect.DeepEqual(world.role("Contributor"), before) || len(world.recorder.events) != 0 {
t.Fatalf("a refused call changes nothing and records nothing")
}
})
}
})
t.Run("a typed-all wildcard raise is an escalation on the article, a typed none is not", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all")
requireEscalationKey(t, err, "content.update:article")
_, err = world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=own")
requireEscalationKey(t, err, "content.update:article")
if _, err = world.update(raTypedManager, "Typed target", "Typed target", "", 1, "content.read=all", "content.update=all", "content.update:article=none"); err != nil {
t.Fatalf("expected success, got %v", err)
}
})
t.Run("the existing role must be covered by the caller", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raNarrowManager, "Contributor", "Contributor", "", 1, contributor()...)
requireEscalationKey(t, err, "content.read")
_, err = world.update(raRoleManager, "Content lead", "Content lead", "", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all")
requireEscalationKey(t, err, "content.update")
if _, err = world.update(raAdmin, "Content lead", "Content lead", "d", 1, "content.read=all", "content.update=all", "content.publish=all", "content.delete=all"); err != nil {
t.Fatalf("expected success, got %v", err)
}
if _, err = world.update(raRoleManager, "Contributor", "Contributor", "harmless", 1, contributor()...); err != nil {
t.Fatalf("expected success, got %v", err)
}
})
t.Run("the order of checks is observable through the fake counters", func(t *testing.T) {
refused := []struct {
name string
caller uint32
target string
desc string
want error
}{
{"locked", raAdmin, "Owner", "", role.ErrRoleLocked},
{"built-in", raAdmin, "Writer", "d", role.ErrMissingPermission},
{"own role", raRoleManager, "Role manager", "d", role.ErrOwnRole},
}
for _, c := range refused {
world := newRolesAPIWorld(t)
world.resetCounters()
if _, err := world.update(c.caller, c.target, c.target, c.desc, 1); !errors.Is(err, c.want) {
t.Fatalf("%s: expected %v, got %v", c.name, c.want, err)
}
if world.roles.calls["Save"] != 0 || world.types.listCalls != 0 || world.roles.calls["List"] != 0 {
t.Errorf("%s: a refusal before the cover check must not list content types or roles, nor save", c.name)
}
}
world := newRolesAPIWorld(t)
world.resetCounters()
if _, err := world.update(raRoleManager, "Content lead", "Content lead", "", 1); !errors.Is(err, role.ErrEscalation) {
t.Fatalf("expected an escalation, got %v", err)
}
if world.roles.calls["Save"] != 0 {
t.Error("an escalation must not save")
}
if _, err := world.update(raRoleManager, "Contributor", "Contributor", "", 7, contributor()...); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("expected a conflict, got %v", err)
}
if world.roles.calls["Save"] != 0 {
t.Error("a stale version must not save")
}
})
t.Run("an unchanged edit writes nothing and records nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
unchanged, err := world.update(raAdmin, "Contributor", "Contributor", "", 1, contributor()...)
if err != nil || unchanged.Version != 1 || world.roles.calls["Save"] != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %+v %v saves=%d events=%d", unchanged, err, world.roles.calls["Save"], len(world.recorder.events))
}
})
t.Run("the editor and time are stored", func(t *testing.T) {
world := newRolesAPIWorld(t)
updated, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...)
if err != nil || updated.UpdatedBy == nil || updated.UpdatedBy.ID != raAdmin || updated.UpdatedAt == nil || !updated.UpdatedAt.Equal(world.now) {
t.Fatalf("unexpected editor %+v at %v: %v", updated.UpdatedBy, updated.UpdatedAt, err)
}
})
t.Run("a save that loses the race gives a conflict", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.beforeSave = func() {
stored := world.role("Contributor")
stored.Version = 2
world.roles.roles[stored.ID] = stored
}
if _, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("expected ErrRoleConflict, got %v", err)
}
if len(world.recorder.events) != 0 {
t.Error("a conflict is a refusal and records nothing")
}
})
t.Run("the edit event holds the diff, the text changes and the members", func(t *testing.T) {
world := newRolesAPIWorld(t)
created, err := world.create(raAdmin, "Reviewer", "content.read=all", "content.update=own", "media.view=all")
if err != nil {
t.Fatal(err)
}
world.recorder.events = nil
edited, err := world.container.UpdateRoleUseCase.Update(context.Background(), useCases.UpdateRoleCommand{
Caller: world.caller(raAdmin), ID: created.ID, Name: "Reviewers", Description: "Reads", Version: 1,
Grants: grantRows("content.read=all", "content.update=all", "content.delete=own"),
})
if err != nil || edited.Version != 2 {
t.Fatalf("unexpected edit %+v %v", edited, err)
}
event := world.recorder.events[0]
if event.Action != "role.edit" || event.Category != audit.CategoryRoles || event.Target.Name != "Reviewers" {
t.Fatalf("unexpected event %+v", event)
}
if !reflect.DeepEqual(event.Params[audit.ParamAdded], grantRows("content.delete=own")) ||
!reflect.DeepEqual(event.Params[audit.ParamRemoved], grantRows("media.view=all")) ||
!reflect.DeepEqual(event.Params[audit.ParamChanged], []role.GrantChange{{Key: "content.update", From: role.ScopeOwn, To: role.ScopeAll}}) ||
event.Params[audit.ParamMembers] != 0 {
t.Fatalf("unexpected params %v", event.Params)
}
fields := map[string][2]string{}
for _, change := range event.Changes {
fields[change.Field] = [2]string{*change.Before, *change.After}
}
if fields[audit.FieldName] != [2]string{"Reviewer", "Reviewers"} || fields[audit.FieldDescription] != [2]string{"", "Reads"} {
t.Fatalf("unexpected changes %v", fields)
}
})
t.Run("a storage failure at the write records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Save")
if _, err := world.update(raAdmin, "Contributor", "Contributor", "d", 1, contributor()...); err == nil {
t.Fatal("expected an error")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].Action != "role.edit" {
t.Fatalf("unexpected events %+v", world.recorder.events)
}
})
t.Run("a legacy admin is not mistaken for the role they edit", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.update(raLegacyAdmin, "Admin", "Admin", "d", 1, adminRows()...)
requireMissingKey(t, err, role.KeyRolesEditOwnerOnly)
})
}
func writerRows() []string {
rows := []string{}
for _, grant := range role.BuiltInRoles()[2].Grants {
rows = append(rows, grant.Key+"="+string(grant.Scope))
}
return rows
}
func adminRows() []string {
rows := []string{}
for _, grant := range role.BuiltInRoles()[1].Grants {
rows = append(rows, grant.Key+"="+string(grant.Scope))
}
return rows
}
func withoutRow(rows []string, removed string) []string {
kept := []string{}
for _, row := range rows {
if row != removed {
kept = append(kept, row)
}
}
return kept
}
func TestResetRoleUseCase(t *testing.T) {
reset := func(world *rolesAPIWorld, caller uint32, roleID uint32, version uint32) (useCases.RoleView, error) {
return world.container.UpdateRoleUseCase.Reset(context.Background(), useCases.ResetRoleCommand{Caller: world.caller(caller), ID: roleID, Version: version})
}
edit := func(world *rolesAPIWorld, name string) {
if _, err := world.update(raOwner, name, name, "custom", 1, "content.read=all"); err != nil {
panic(err)
}
world.recorder.events = nil
}
t.Run("the owner restores the seeded grants and description, bumping the version once", func(t *testing.T) {
for _, name := range []string{"Writer", "Admin"} {
world := newRolesAPIWorld(t)
edit(world, name)
restored, err := reset(world, raOwner, world.ids[name], 2)
if err != nil || restored.Version != 3 || restored.Description != "" || restored.Name != name || !restored.BuiltIn {
t.Fatalf("%s: unexpected role %+v %v", name, restored, err)
}
seeded := role.Role{Grants: world.roles.byName(name).Grants}
want := role.BuiltInRoles()[map[string]int{"Admin": 1, "Writer": 2}[name]].Grants
if !reflect.DeepEqual(seeded.EffectiveGrants(), role.Role{Grants: want}.EffectiveGrants()) {
t.Fatalf("%s: grants are not the seeded ones", name)
}
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("a pristine role is a no-op", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.resetCounters()
same, err := reset(world, raOwner, world.ids["Writer"], 1)
if err != nil || same.Version != 1 || world.roles.calls["Save"] != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %+v %v", same, err)
}
})
t.Run("refusals", func(t *testing.T) {
world := newRolesAPIWorld(t)
edit(world, "Writer")
cases := []struct {
name string
caller uint32
id uint32
version uint32
want error
}{
{"stale version", raOwner, world.ids["Writer"], 1, role.ErrRoleConflict},
{"owner role", raOwner, world.ids["Owner"], 1, role.ErrRoleLocked},
{"custom role", raOwner, world.ids["Contributor"], 1, role.ErrNotBuiltIn},
{"missing role", raOwner, raUnknownRoleID, 1, role.ErrRoleNotFound},
{"admin caller", raAdmin, world.ids["Writer"], 2, role.ErrMissingPermission},
}
for _, c := range cases {
if _, err := reset(world, c.caller, c.id, c.version); !errors.Is(err, c.want) {
t.Errorf("%s: expected %v, got %v", c.name, c.want, err)
}
}
if len(world.recorder.events) != 0 {
t.Error("a refused reset records nothing")
}
})
}
func TestDeleteRoleUseCase(t *testing.T) {
rows := []struct {
name string
caller uint32
target string
want error
key string
}{
{"owner deletes intern", raOwner, "Intern", nil, ""},
{"admin deletes intern", raAdmin, "Intern", nil, ""},
{"admin deletes contributor", raAdmin, "Contributor", role.ErrRoleHasMembers, ""},
{"owner deletes writer", raOwner, "Writer", role.ErrBuiltInRole, ""},
{"owner deletes admin", raOwner, "Admin", role.ErrBuiltInRole, ""},
{"owner deletes owner", raOwner, "Owner", role.ErrRoleLocked, ""},
{"admin deletes owner", raAdmin, "Owner", role.ErrRoleLocked, ""},
{"admin deletes a missing role", raAdmin, "Missing", role.ErrRoleNotFound, ""},
{"role manager deletes their own role", raRoleManager, "Role manager", role.ErrOwnRole, ""},
{"role manager deletes content lead", raRoleManager, "Content lead", role.ErrEscalation, "content.update"},
{"role manager deletes intern", raRoleManager, "Intern", nil, ""},
}
for _, c := range rows {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
err := world.remove(c.caller, c.target)
if c.want == nil {
if err != nil {
t.Fatalf("expected success, got %v", err)
}
if _, gone := world.roles.roles[world.ids[c.target]]; gone {
t.Fatal("the role must be gone")
}
event := world.recorder.events[0]
if event.Action != "role.delete" || event.Params[audit.ParamMembers] != 0 || event.Target.Name != c.target {
t.Fatalf("unexpected event %+v", event)
}
return
}
if !errors.Is(err, c.want) {
t.Fatalf("expected %v, got %v", c.want, err)
}
if c.key != "" {
requireEscalationKey(t, err, c.key)
}
if len(world.recorder.events) != 0 {
t.Fatal("a refusal records nothing")
}
if c.want != role.ErrRoleNotFound {
if _, kept := world.roles.roles[world.ids[c.target]]; !kept {
t.Fatal("a refused delete deletes nothing")
}
}
})
}
t.Run("a member appearing meanwhile still gives role_has_members and no event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNextWith("Delete", role.ErrRoleHasMembers)
if err := world.remove(raAdmin, "Intern"); !errors.Is(err, role.ErrRoleHasMembers) || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %v events=%d", err, len(world.recorder.events))
}
})
t.Run("a storage failure records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("Delete")
if err := world.remove(raAdmin, "Intern"); err == nil {
t.Fatal("expected an error")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed {
t.Fatalf("unexpected events %+v", world.recorder.events)
}
})
}
func TestReassignRoleMembersUseCase(t *testing.T) {
t.Run("moving the contributors to Writer", func(t *testing.T) {
world := newRolesAPIWorld(t)
before := world.users.users[raContributorOne]
moved, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"])
if err != nil || moved != 4 {
t.Fatalf("expected 4 moved, got %d %v", moved, err)
}
after := world.users.users[raContributorOne]
if after.RoleID != world.ids["Writer"] {
t.Fatal("the member was not moved")
}
before.RoleID = after.RoleID
if !reflect.DeepEqual(before, after) {
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 {
t.Fatalf("unexpected event %+v", event)
}
if err := world.remove(raAdmin, "Contributor"); err != nil {
t.Fatalf("the emptied role must be deletable, got %v", err)
}
})
t.Run("moving nobody records nothing", func(t *testing.T) {
world := newRolesAPIWorld(t)
moved, err := world.reassign(raAdmin, "Intern", world.ids["Writer"])
if err != nil || moved != 0 || len(world.recorder.events) != 0 {
t.Fatalf("unexpected result %d %v events=%d", moved, err, len(world.recorder.events))
}
})
refusals := []struct {
name string
caller uint32
source string
target func(*rolesAPIWorld) uint32
want error
}{
{"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},
{"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},
{"unknown source", raAdmin, "Missing", func(w *rolesAPIWorld) uint32 { return w.ids["Writer"] }, role.ErrRoleNotFound},
{"own role as source", raRoleManager, "Role manager", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrOwnRole},
{"source above the caller", raRoleManager, "Content lead", func(w *rolesAPIWorld) uint32 { return w.ids["Intern"] }, role.ErrEscalation},
}
for _, c := range refusals {
t.Run(c.name, func(t *testing.T) {
world := newRolesAPIWorld(t)
moved, err := world.reassign(c.caller, c.source, c.target(world))
if !errors.Is(err, c.want) || moved != 0 {
t.Fatalf("expected %v, got %d %v", c.want, moved, err)
}
if len(world.recorder.events) != 0 || len(world.members("Contributor")) != 4 {
t.Fatal("a refusal moves nobody and records nothing")
}
})
}
t.Run("a role manager may move members to a role they cover", func(t *testing.T) {
world := newRolesAPIWorld(t)
moved, err := world.reassign(raRoleManager, "Contributor", world.ids["Intern"])
if err != nil || moved != 4 {
t.Fatalf("expected 4 moved, got %d %v", moved, err)
}
})
t.Run("a repository failure moves nobody and records a failed event", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("ReassignMembers")
if _, err := world.reassign(raAdmin, "Contributor", world.ids["Writer"]); err == nil {
t.Fatal("expected an error")
}
if len(world.members("Contributor")) != 4 {
t.Fatal("nobody may be moved")
}
if len(world.recorder.events) != 1 || world.recorder.events[0].Outcome != audit.OutcomeFailed || world.recorder.events[0].Action != "role.reassign" {
t.Fatalf("unexpected events %+v", world.recorder.events)
}
})
}
func TestContentTypeTypedGrantCleanup(t *testing.T) {
t.Run("deleting a content type removes its typed rows and bumps only the affected roles", func(t *testing.T) {
world := newRolesAPIWorld(t)
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err != nil {
t.Fatal(err)
}
editor := world.role("Events editor")
if !reflect.DeepEqual(editor.Grants, grantRows("content.read=all", "content.update:venues=all")) || editor.Version != 2 {
t.Fatalf("unexpected editor role %v version %d", editor.Grants, editor.Version)
}
if world.role("Contributor").Version != 1 {
t.Fatal("a role without typed rows keeps its version")
}
})
t.Run("a refused delete keeps the typed rows", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.entries.entries[1] = entryOfType(raEventsTypeID)
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err == nil {
t.Fatal("expected a refusal")
}
if world.role("Events editor").Version != 1 || world.roles.calls["RemoveTypedGrants"] != 0 {
t.Fatal("nothing may be removed")
}
})
t.Run("a failure while removing the rows is reported", func(t *testing.T) {
world := newRolesAPIWorld(t)
world.roles.fail.failNext("RemoveTypedGrants")
if err := world.container.DeleteContentTypeUseCase.Delete("events"); err == nil {
t.Fatal("expected an error")
}
})
t.Run("creating a content type clears the stale typed rows of its key", 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")})
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err != nil {
t.Fatal(err)
}
if got := world.roles.roles[stale.ID]; len(got.Grants) != 0 || got.Version != 2 {
t.Fatalf("unexpected role %+v", got)
}
})
t.Run("a refused create keeps the rows", func(t *testing.T) {
world := newRolesAPIWorld(t)
_, err := world.container.CreateContentTypeUseCase.Create(useCases.CreateContentTypeCommand{Key: "events", Name: "Events", Fields: validContentTypeFields()})
if err == nil {
t.Fatal("expected the key to be taken")
}
if world.roles.calls["RemoveTypedGrants"] != 0 {
t.Fatal("nothing may be removed")
}
})
}
func TestGetMyAccessTypedGrants(t *testing.T) {
t.Run("the events editor lists typed entries including none, in order", func(t *testing.T) {
world := newRolesAPIWorld(t)
access, err := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[raEventsEditor])
if err != nil {
t.Fatal(err)
}
want := grantRows("content.read=all", "content.update:events=own", "content.update:venues=all", "content.delete:events=none")
if !reflect.DeepEqual(access.Permissions, want) {
t.Fatalf("expected %v, got %v", want, access.Permissions)
}
})
t.Run("an unlocked role never lists an owner-only key", func(t *testing.T) {
world := newRolesAPIWorld(t)
hand, _ := world.roles.Create(role.Role{Name: "Hand placed", Version: 1, Grants: grantRows("system.apply_updates=all", "content.read=all", "content.read=own")})
world.addUser(60, user.RoleAdmin, hand.ID, true)
access, _ := useCases.NewGetMyAccessUseCase(world.roles).Get(world.users.users[60])
if !reflect.DeepEqual(access.Permissions, grantRows("content.read=own")) {
t.Fatalf("unexpected permissions %v", access.Permissions)
}
})
}
+206
View File
@@ -0,0 +1,206 @@
package test
import (
"RenewCMS/internal/domain/contenttype"
"RenewCMS/internal/domain/entry"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
const (
raOwner uint32 = 3
raAdmin uint32 = 5
raWriter uint32 = 9
raLegacyAdmin uint32 = 10
raUnverified uint32 = 12
raRoleManager uint32 = 31
raViewer uint32 = 32
raTypedManager uint32 = 33
raNarrowManager uint32 = 34
raEventsEditor uint32 = 35
raContributorOne uint32 = 41
raContentLead uint32 = 45
raEventsTypeID uint32 = 1
raVenuesTypeID uint32 = 2
raUnknownRoleID uint32 = 999
)
type rolesAPIRoleSpec struct {
name string
grants []string
members []uint32
}
var rolesAPICustomRoles = []rolesAPIRoleSpec{
{"Contributor", []string{"content.read=all", "content.create=all", "content.update=own"}, []uint32{41, 42, 43, 44}},
{"Intern", []string{"content.read=own"}, nil},
{"Content lead", []string{"content.read=all", "content.update=all", "content.publish=all", "content.delete=all"}, []uint32{raContentLead}},
{"Role manager", []string{"roles.view=all", "roles.manage=all", "content.read=all", "content.create=all", "content.update=own", "content.publish=own", "schema.view=all"}, []uint32{raRoleManager}},
{"Viewer", []string{"roles.view=all"}, []uint32{raViewer}},
{"Typed manager", []string{"roles.view=all", "roles.manage=all", "content.read=all", "content.update=all", "content.update:article=none"}, []uint32{raTypedManager}},
{"Narrow manager", []string{"roles.view=all", "roles.manage=all", "content.read:events=all"}, []uint32{raNarrowManager}},
{"Events editor", []string{"content.read=all", "content.update:events=own", "content.delete:events=none", "content.update:venues=all"}, []uint32{raEventsEditor}},
{"Typed target", []string{"content.read=all", "content.update=own", "content.update:article=none"}, nil},
}
type rolesAPIWorld struct {
*apiKeyEnv
recorder *contentScopeRecorder
types *mockContentTypeRepo
entries *mockEntryRepo
ids map[string]uint32
}
func newRolesAPIWorld(t *testing.T) *rolesAPIWorld {
t.Helper()
recorder := &contentScopeRecorder{}
env := newAPIKeyEnvRecording(t, recorder)
world := &rolesAPIWorld{apiKeyEnv: env, recorder: recorder, types: newMockContentTypeRepo(), entries: newMockEntryRepo(), ids: map[string]uint32{}}
env.users.users = map[uint32]user.User{}
env.roles.users = env.users
world.seedBuiltIns()
world.seedRoles()
world.seedContentTypes()
world.wire()
return world
}
func (w *rolesAPIWorld) seedBuiltIns() {
w.ids["Owner"], w.ids["Admin"], w.ids["Writer"] = ownerRoleID, adminRoleID, writerRoleID
w.addUser(raOwner, user.RoleOwner, ownerRoleID, true)
w.addUser(raAdmin, user.RoleAdmin, adminRoleID, true)
w.addUser(raLegacyAdmin, user.RoleAdmin, 0, true)
w.addUser(raWriter, "", writerRoleID, true)
w.addUser(raUnverified, user.RoleAdmin, adminRoleID, false)
}
func (w *rolesAPIWorld) seedRoles() {
for _, spec := range rolesAPICustomRoles {
created, _ := w.roles.Create(role.Role{Name: spec.name, Version: 1, Grants: grantRows(spec.grants...)})
w.ids[spec.name] = created.ID
for _, member := range spec.members {
w.addUser(member, "", created.ID, true)
}
}
}
func (w *rolesAPIWorld) seedContentTypes() {
title := contenttype.Field{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}
w.types.types[raEventsTypeID] = contenttype.ContentType{ID: raEventsTypeID, Key: "events", Name: "Events", Fields: []contenttype.Field{title}}
w.types.types[raVenuesTypeID] = contenttype.ContentType{ID: raVenuesTypeID, Key: "venues", Name: "Venues", Fields: []contenttype.Field{title}}
w.types.nextID = 10
}
func validContentTypeFields() []contenttype.Field {
return []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText, Required: true, Options: []string{}}}
}
func entryOfType(typeID uint32) entry.Entry {
return entry.Entry{ID: 1, ContentTypeID: typeID}
}
func (w *rolesAPIWorld) clock() time.Time {
return w.now
}
func (w *rolesAPIWorld) wire() {
c := w.container
c.ListRolesUseCase = useCases.NewListRolesUseCase(w.roles, w.users, w.types)
c.GetRoleUseCase = useCases.NewGetRoleUseCase(w.roles, w.users, w.types)
c.CreateRoleUseCase = useCases.NewCreateRoleUseCase(w.roles, w.users, w.types, w.recorder)
c.UpdateRoleUseCase = useCases.NewUpdateRoleUseCase(w.roles, w.users, w.types, w.recorder, w.clock)
c.DeleteRoleUseCase = useCases.NewDeleteRoleUseCase(w.roles, w.users, w.types, w.recorder)
c.ReassignRoleMembersUseCase = useCases.NewReassignRoleMembersUseCase(w.roles, w.users, w.types, w.recorder)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.types, w.roles, w.clock)
c.ListContentTypesUseCase = useCases.NewListContentTypesUseCase(w.types)
c.GetContentTypeUseCase = useCases.NewGetContentTypeUseCase(w.types)
c.UpdateContentTypeUseCase = useCases.NewUpdateContentTypeUseCase(w.types, w.entries, w.clock)
c.DeleteContentTypeUseCase = useCases.NewDeleteContentTypeUseCase(w.types, w.entries, w.roles)
w.rebuildRouter()
}
func (w *rolesAPIWorld) role(name string) role.Role {
found, err := w.roles.Get(w.ids[name])
if err != nil {
panic("unknown role " + name)
}
return found
}
func (w *rolesAPIWorld) path(name string) string {
return "/v1/role/" + strconv.FormatUint(uint64(w.ids[name]), 10)
}
func (w *rolesAPIWorld) caller(userID uint32) useCases.ContentCaller {
account := w.users.users[userID]
roleID := account.RoleID
if roleID == 0 {
roleID = w.ids[map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"}[account.Role]]
}
return useCases.ContentCaller{UserID: userID, Grants: w.roles.roles[roleID].GrantSet()}
}
func (w *rolesAPIWorld) members(name string) []uint32 {
found := []uint32{}
for _, id := range w.users.sortedIds() {
if w.users.users[id].RoleID == w.ids[name] {
found = append(found, id)
}
}
return found
}
func (w *rolesAPIWorld) do(t *testing.T, caller uint32, method string, path string, body string) *httptest.ResponseRecorder {
t.Helper()
return w.send(t, apiKeyRequest{method: method, path: path, body: body, cookieUser: caller})
}
func (w *rolesAPIWorld) resetCounters() {
w.roles.calls = map[string]int{}
w.types.listCalls = 0
}
func rolesAPIUpdateBody(name string, description string, version uint32, grants ...string) string {
return `{"name": ` + strconv.Quote(name) + `, "description": ` + strconv.Quote(description) + `, "version": ` + strconv.FormatUint(uint64(version), 10) + `, "grants": ` + rolesAPIGrantsJSON(grants...) + `}`
}
func rolesAPIGrantsJSON(rows ...string) string {
body := "["
for index, grant := range grantRows(rows...) {
if index > 0 {
body += ", "
}
body += `{"key": ` + strconv.Quote(grant.Key) + `, "scope": ` + strconv.Quote(string(grant.Scope)) + `}`
}
return body + "]"
}
func rolesAPIContributorRows(extra ...string) []string {
return append([]string{"content.read=all", "content.create=all", "content.update=own"}, extra...)
}
func assertRefusal(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if rec.Code != status || rec.Body.String() != code+"\n" {
t.Fatalf("expected %d %q, got %d %q", status, code, rec.Code, rec.Body.String())
}
if rec.Header().Get("X-Missing-Permission") != "" || rec.Header().Get("X-Escalation-Key") != "" {
t.Fatalf("unexpected permission header on %q", code)
}
}
func assertEscalation(t *testing.T, rec *httptest.ResponseRecorder, key string) {
t.Helper()
if rec.Code != http.StatusForbidden || rec.Body.String() != "escalation\n" {
t.Fatalf("expected 403 escalation, got %d %q", rec.Code, rec.Body.String())
}
if header := rec.Header().Get("X-Escalation-Key"); key != "" && header != key {
t.Fatalf("expected X-Escalation-Key %q, got %q", key, header)
}
}
+11 -11
View File
@@ -583,8 +583,8 @@ func TestContentScopeRequestLoads(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 1 user Get, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
if world.userRepo.gets > 1 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
t.Errorf("expected at most 1 user Get, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
}
})
t.Run("An API key request loads the user at most twice: "+route[0]+" "+route[1], func(t *testing.T) {
@@ -595,30 +595,30 @@ func TestContentScopeRequestLoads(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("unexpected %d %q", rec.Code, rec.Body.String())
}
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.listGrantsN != 0 {
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 ListGrants, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.listGrantsN)
if world.userRepo.gets > 2 || world.roleRepo.gets != 1 || world.roleRepo.lists != 0 {
t.Errorf("expected at most 2 user Gets, 1 role Get and 0 List calls, got %d, %d, %d", world.userRepo.gets, world.roleRepo.gets, world.roleRepo.lists)
}
})
}
t.Run("A legacy owner is resolved by one GetByName and no ListGrants", func(t *testing.T) {
t.Run("A legacy owner is resolved by one GetByName and no List call", func(t *testing.T) {
world := newContentScopeWorld(t)
legacy := world.users.users[csOwner]
legacy.RoleID = 0
world.users.users[csOwner] = legacy
world.resetCounters()
assertScopeWant(t, world.do(t, csOwner, http.MethodPatch, "/v1/article/10", csArticlePatch), okWant(200))
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.listGrantsN != 0 {
if world.roleRepo.getByNames != 1 || world.roleRepo.gets != 0 || world.roleRepo.lists != 0 {
t.Errorf("unexpected role loads %+v", world.roleRepo)
}
})
t.Run("GET /auth/me does not call ListGrants", func(t *testing.T) {
t.Run("GET /auth/me does not call List", func(t *testing.T) {
world := newContentScopeWorld(t)
world.resetCounters()
rec, _ := world.me(t, csWriter)
if rec.Code != http.StatusOK || world.roleRepo.listGrantsN != 0 {
t.Errorf("unexpected %d and %d ListGrants calls", rec.Code, world.roleRepo.listGrantsN)
if rec.Code != http.StatusOK || world.roleRepo.lists != 0 {
t.Errorf("unexpected %d and %d List calls", rec.Code, world.roleRepo.lists)
}
})
}
@@ -654,8 +654,8 @@ func TestContentScopeOwnerOnlyAndMe(t *testing.T) {
for _, grant := range me.Access.Permissions {
got[grant.Key] = string(grant.Scope)
}
if got["content.update"] != "own" || len(got) != 1 {
t.Errorf("expected only content.update at own, got %v", got)
if got["content.update"] != "own" || got["content.update:blog"] != "all" || len(got) != 2 {
t.Errorf("expected content.update at own and the typed entry (superseded by roles_permissions_roles_api), got %v", got)
}
})
}
+7 -6
View File
@@ -9,6 +9,7 @@ import (
"RenewCMS/internal/infrastructure/useCases"
"context"
"errors"
"reflect"
"testing"
"time"
)
@@ -86,14 +87,14 @@ func TestContentScopeCheckPermissionUseCase(t *testing.T) {
if _, err := check.Require(writer, "content.read"); err != nil {
t.Fatal(err)
}
if counting.gets != 1 || counting.getByNames != 0 || counting.listGrantsN != 0 {
if counting.gets != 1 || counting.getByNames != 0 || counting.lists != 0 {
t.Errorf("unexpected loads %+v", counting)
}
counting.gets = 0
if _, err := check.Require(user.User{ID: 4, Role: user.RoleAdmin}, "content.read"); err != nil {
t.Fatal(err)
}
if counting.gets != 0 || counting.getByNames != 1 || counting.listGrantsN != 0 {
if counting.gets != 0 || counting.getByNames != 1 || counting.lists != 0 {
t.Errorf("unexpected legacy loads %+v", counting)
}
})
@@ -120,10 +121,10 @@ func TestContentScopeGetMyAccessUseCase(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(access.Permissions) != 1 || access.Permissions[0].Key != "content.update" || access.Permissions[0].Scope != role.ScopeOwn {
t.Errorf("unexpected permissions %+v", access.Permissions)
if !reflect.DeepEqual(access.Permissions, grantRows("content.update=own", "content.update:blog=all")) {
t.Errorf("unexpected permissions %+v (typed entries superseded by roles_permissions_roles_api)", access.Permissions)
}
if counting.gets != 1 || counting.listGrantsN != 0 {
if counting.gets != 1 || counting.lists != 0 {
t.Errorf("unexpected loads %+v", counting)
}
})
@@ -478,7 +479,7 @@ func TestContentScopeCreateContentTypeReservesBuiltInKeys(t *testing.T) {
for _, key := range []string{"article", "author", "site", "media", "user"} {
t.Run(key, func(t *testing.T) {
types := newMockContentTypeRepo()
create := useCases.NewCreateContentTypeUseCase(types, time.Now)
create := useCases.NewCreateContentTypeUseCase(types, newMockRoleRepo(), time.Now)
_, err := create.Create(useCases.CreateContentTypeCommand{Key: key, Name: "Reserved", Fields: []contenttype.Field{{Key: "title", Label: "Title", Type: contenttype.TypeText}}})
if !errors.Is(err, contenttype.ErrKeyTaken) || types.createCalls != 0 {
t.Errorf("expected ErrKeyTaken and nothing stored, got %v (%d)", err, types.createCalls)
+9 -9
View File
@@ -61,9 +61,9 @@ func (r *contentScopeRecorder) actions() []string {
type contentScopeRoleRepo struct {
role.Repository
gets int
getByNames int
listGrantsN int
gets int
getByNames int
lists int
}
func (c *contentScopeRoleRepo) Get(id uint32) (role.Role, error) {
@@ -76,13 +76,13 @@ func (c *contentScopeRoleRepo) GetByName(name string) (role.Role, error) {
return c.Repository.GetByName(name)
}
func (c *contentScopeRoleRepo) ListGrants(roleID uint32) ([]role.Grant, error) {
c.listGrantsN++
return c.Repository.ListGrants(roleID)
func (c *contentScopeRoleRepo) List() ([]role.Role, error) {
c.lists++
return c.Repository.List()
}
func (c *contentScopeRoleRepo) touched() int {
return c.gets + c.getByNames + c.listGrantsN
return c.gets + c.getByNames + c.lists
}
type contentScopeUserRepo struct {
@@ -219,7 +219,7 @@ func (w *contentScopeWorld) wire() {
c.AuthenticateAPIKeyUseCase = useCases.NewAuthenticateAPIKeyUseCase(w.keys, w.generator, w.userRepo, clock)
c.ResolveArticleSEOUseCase = useCases.NewResolveArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CheckArticleSEOUseCase = useCases.NewCheckArticleSEOUseCase(w.articles, plainSites(), w.images)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, clock)
c.CreateContentTypeUseCase = useCases.NewCreateContentTypeUseCase(w.typeRepo, w.roles, clock)
c.CreateEntryUseCase = useCases.NewCreateEntryUseCase(w.typeRepo, w.entries, w.images, clock)
c.GetEntryUseCase = useCases.NewGetEntryUseCase(w.typeRepo, w.entries, w.images)
c.ListEntriesUseCase = useCases.NewListEntriesUseCase(w.typeRepo, w.entries, w.images)
@@ -234,7 +234,7 @@ func (w *contentScopeWorld) do(t *testing.T, caller uint32, method string, path
}
func (w *contentScopeWorld) resetCounters() {
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.listGrantsN = 0, 0, 0
w.roleRepo.gets, w.roleRepo.getByNames, w.roleRepo.lists = 0, 0, 0
w.userRepo.gets = 0
w.typeRepo.lookups = 0
}