feat: roles system backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 05:02:49 +01:00
1 parent 44cca46d5e
commit 8c6c6456da
50 files changed
+2578 -22

No files matched your search

+7
View File
@@ -12,6 +12,7 @@ import (
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainPurge "RenewCMS/internal/domain/purge"
domainRole "RenewCMS/internal/domain/role"
domainSession "RenewCMS/internal/domain/session"
domainSite "RenewCMS/internal/domain/site"
domainTransaction "RenewCMS/internal/domain/transaction"
@@ -62,6 +63,8 @@ type UseCases struct {
UpdateAuthorUseCase *useCases.UpdateAuthorUseCase
DeleteAuthorUseCase *useCases.DeleteAuthorUseCase
GetMyAuthorUseCase *useCases.GetMyAuthorUseCase
GetMyAccessUseCase *useCases.GetMyAccessUseCase
ListPermissionsUseCase *useCases.ListPermissionsUseCase
ListSitesUseCase *useCases.ListSitesUseCase
CreateSiteUseCase *useCases.CreateSiteUseCase
UpdateSiteUseCase *useCases.UpdateSiteUseCase
@@ -161,6 +164,7 @@ type Repositories struct {
EntryRepo domainEntry.Repository
AuthorRepo domainAuthor.Repository
UserRepo domainUser.Repository
RoleRepo domainRole.Repository
ImageRepo domainImage.Repository
Transactor domainTransaction.Transactor
VariantRepo domainImage.VariantRepository
@@ -305,6 +309,7 @@ func getRepositories(connection *database.Connection, settings config.Settings)
EntryRepo: persistence.NewEntryRepository(db),
AuthorRepo: persistence.NewAuthorRepository(db),
UserRepo: persistence.NewUserRepository(db),
RoleRepo: persistence.NewRoleRepository(db),
ImageRepo: persistence.NewImageRepository(db),
Transactor: persistence.NewTransactor(db),
VariantRepo: persistence.NewVariantRepository(db),
@@ -366,6 +371,8 @@ func getUseCases(repos *Repositories, imageStorage domainImage.Storage, settings
UpdateAuthorUseCase: useCases.NewUpdateAuthorUseCase(repos.AuthorRepo, repos.UserRepo, repos.ImageRepo, recorder),
DeleteAuthorUseCase: useCases.NewDeleteAuthorUseCase(repos.AuthorRepo, recorder),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(repos.AuthorRepo),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(repos.RoleRepo),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
ListSitesUseCase: useCases.NewListSitesUseCase(repos.SiteRepo),
CreateSiteUseCase: useCases.NewCreateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
UpdateSiteUseCase: useCases.NewUpdateSiteUseCase(repos.SiteRepo, repos.ImageRepo, recorder),
+31 -2
View File
@@ -5,6 +5,7 @@ import (
"RenewCMS/api/pkg/claims"
"RenewCMS/api/pkg/publicuser"
"RenewCMS/internal/domain/author"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
@@ -15,12 +16,14 @@ import (
type MeHandler struct {
GetUseCase *useCases.GetUserUseCase
GetAuthorUseCase *useCases.GetMyAuthorUseCase
GetAccessUseCase *useCases.GetMyAccessUseCase
}
func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase) *MeHandler {
func NewMeHandler(getUser *useCases.GetUserUseCase, getAuthor *useCases.GetMyAuthorUseCase, getAccess *useCases.GetMyAccessUseCase) *MeHandler {
return &MeHandler{
GetUseCase: getUser,
GetAuthorUseCase: getAuthor,
GetAccessUseCase: getAccess,
}
}
@@ -29,9 +32,29 @@ type meAuthor struct {
ArticleCount int `json:"article_count"`
}
type meRole struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
}
type meAccess struct {
Role meRole `json:"role"`
Permissions []role.Grant `json:"permissions"`
}
type meResponse struct {
publicuser.PublicUser
Author *meAuthor `json:"author"`
Access meAccess `json:"access"`
}
func toMeAccess(access useCases.Access) meAccess {
return meAccess{
Role: meRole{ID: access.Role.ID, Name: access.Role.Name, BuiltIn: access.Role.BuiltIn, Locked: access.Role.Locked},
Permissions: access.Permissions,
}
}
func toMeAuthor(found *author.Author) *meAuthor {
@@ -64,7 +87,13 @@ func (h *MeHandler) Me(w http.ResponseWriter, r *http.Request) {
return
}
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked)})
access, err := h.GetAccessUseCase.Get(loadedUser)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
body, err := json.Marshal(meResponse{PublicUser: publicuser.New(loadedUser), Author: toMeAuthor(linked), Access: toMeAccess(access)})
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
@@ -0,0 +1,53 @@
package permission
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
)
type Handler struct {
ListUseCase *useCases.ListPermissionsUseCase
}
func NewHandler(list *useCases.ListPermissionsUseCase) *Handler {
return &Handler{ListUseCase: list}
}
type permissionItem struct {
Key string `json:"key"`
ScopeKind role.Kind `json:"scope_kind"`
OwnerOnly bool `json:"owner_only"`
PerType bool `json:"per_type"`
}
type permissionGroup struct {
Key string `json:"key"`
Items []permissionItem `json:"items"`
}
type permissionResponse struct {
Groups []permissionGroup `json:"groups"`
}
func toResponse(groups []useCases.PermissionGroup) permissionResponse {
response := permissionResponse{Groups: make([]permissionGroup, 0, len(groups))}
for _, group := range groups {
items := make([]permissionItem, 0, len(group.Items))
for _, permission := range group.Items {
items = append(items, permissionItem{Key: permission.Key, ScopeKind: permission.Kind, OwnerOnly: permission.OwnerOnly, PerType: permission.PerType()})
}
response.Groups = append(response.Groups, permissionGroup{Key: group.Key, Items: items})
}
return response
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
body, err := json.Marshal(toResponse(h.ListUseCase.List()))
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
+17 -1
View File
@@ -13,6 +13,7 @@ import (
"RenewCMS/api/handlers/imagevariant"
"RenewCMS/api/handlers/llms"
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/permission"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/site"
"RenewCMS/api/handlers/staticimages"
@@ -92,7 +93,7 @@ func NewAuthRouter(container *UseCases, isProduction bool, rateLimit config.Auth
loginHandler := auth.NewLoginHandler(container.Token, container.CreateUserUseCase, container.AuthenticateUserUseCase, isProduction)
verificationHandler := auth.NewVerificationHandler(container.VerifyUserUseCase, container.CancelRegistrationUseCase, isProduction)
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase)
meHandler := auth.NewMeHandler(container.GetUserUseCase, container.GetMyAuthorUseCase, container.GetMyAccessUseCase)
passwordResetHandler := auth.NewPasswordResetHandler(container.RequestPasswordResetUseCase, container.ResetPasswordUseCase)
acceptInviteHandler := auth.NewAcceptInviteHandler(container.GetInviteUseCase, container.AcceptInviteUseCase, container.Token, isProduction)
@@ -375,6 +376,20 @@ func NewAPIKeyRouter(container *UseCases, trustedProxies []string) http.Handler
return r
}
func NewPermissionRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
permissionHandler := permission.NewHandler(container.ListPermissionsUseCase)
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.Get("/", permissionHandler.List)
return r
}
func NewMediaRouter(container *UseCases, trustedProxies []string) http.Handler {
r := chi.NewRouter()
@@ -457,6 +472,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/media", NewMediaRouter(container, settings.TrustedProxies))
r.Mount("/image-variants", NewImageVariantRouter(container, settings.TrustedProxies))
r.Mount("/api-key", NewAPIKeyRouter(container, settings.TrustedProxies))
r.Mount("/permission", NewPermissionRouter(container))
r.Mount("/system", NewSystemRouter(container, settings.TrustedProxies))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
+8
View File
@@ -111,6 +111,14 @@ The cache screen adds three ports in `internal/domain/image`: `VariantCacheRepos
At boot, `BackfillImageMetadataUseCase` (through `image.Repository.ListMissingMetadata` and `UpdateMetadata`) fills `filename`, `size`, `content_type`, `width`, `height` and `sha256` for image rows that lack them, reading the bytes through `image.Storage` (the files in `UPLOAD_DIR` with the local adapter, the bucket objects with the S3 adapter). It is idempotent, and a row whose file is missing or unreadable is left as it is without stopping the boot. The old `article_id` column and the `article_images` table are kept and never written.
### Roles and permissions
`internal/domain/role` holds the permission catalogue, the built-in roles and the pure rules over grants: `ValidateGrants`, `Role.Allows`, `Role.AllowsFor` and `ParseTypedKey`. A toggle permission only counts at scope `all`, a scoped one at `own` or `all`, and an owner-only permission only counts on the locked Owner role. A content permission can be narrowed or widened per content type with a key such as `content.update:blog`; the specific grant overrides the wildcard, and without an override the wildcard applies. The existence of the type key is not checked in the domain. `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`.
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.
Created-by backfill: `database.BackfillCreatedBy` assigns articles (soft-deleted ones included) and entries whose `created_by` is 0 to the first owner by id. A database without an owner is left alone. Entries have no soft delete, so no unscoped query is needed for them.
## Frontend ↔ backend
```
@@ -0,0 +1,293 @@
Feature: Roles and permissions foundation: catalogue, built-in roles, backfills and read endpoints
As the owner of a RenewCMS site
I want permissions to be a fixed catalogue and roles to be stored sets of (permission, scope)
So that later slices can replace the fixed owner/admin checks without changing what existing
owners and admins can do today
Background:
Given the domain package internal/domain/role holds, with the stdlib only:
- the catalogue: a fixed ordered list of 34 permission keys in 9 groups
- 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 pure function Role.Allows(key, owned bool) bool
And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only):
| group | key | kind | owner-only |
| content | content.read | S | no |
| content | content.create | B | no |
| content | content.update | S | no |
| content | content.delete | S | no |
| content | content.publish | S | no |
| schema | schema.view | B | no |
| schema | schema.edit | B | no |
| schema | schema.delete | B | no |
| media | media.view | B | no |
| media | media.upload | B | no |
| media | media.delete | S | no |
| authors | authors.view | B | no |
| authors | authors.create | B | no |
| authors | authors.edit | S | no |
| authors | authors.delete | B | no |
| activity | activity.view | S | no |
| activity | activity.export | B | no |
| users | users.view | B | no |
| users | users.invite | B | no |
| users | users.change_role | B | no |
| users | users.remove | B | no |
| settings | settings.view | B | no |
| settings | settings.edit_general | B | no |
| settings | settings.manage_sites | B | no |
| settings | settings.delete_site | B | yes |
| settings | apikeys.view | S | no |
| settings | apikeys.create | B | no |
| settings | apikeys.revoke | S | no |
| roles | roles.view | B | no |
| roles | roles.manage | B | no |
| roles | roles.edit_owner_only | B | yes |
| system | system.apply_updates | B | yes |
| 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
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,
with built_in true and version 1:
| name | locked | granted keys |
| Owner | true | all 34 keys at all |
| Admin | false | the 29 keys that are not owner-only, at all |
| Writer | false | content.read=all, content.create=all, content.update=all, schema.view=all, media.view=all, media.upload=all, authors.view=all, authors.edit=own, activity.view=own |
And Admin keeps roles.view and roles.manage (confirmed by the user)
And users gain a nullable-by-default role_id referencing role. The legacy users.role string, the
domain user.Role constants and every RequireOwner / RequireOwnerOrAdmin check are unchanged
in this slice
And articles gain created_by (user id). Entries already carry created_by
And Migrate registers role and role_permission in AutoMigrate, then runs in this order:
seed built-in roles, the existing BackfillUserRoles, BackfillUserRoleIDs, BackfillCreatedBy
And the use cases are GetMyAccessUseCase and ListPermissionsUseCase. They depend on domain
interfaces only. Handlers receive use cases through constructors
And GET /v1/auth/me keeps its current contract (features/get_current_user.feature) and adds one
top-level field, access. No /v1/me route is added
And GET /v1/permission is mounted in api/router.go (NewPermissionRouter) behind
authtoken.Verifier, container.apiKeyAuth(), authtoken.Authenticator, RequireCurrentToken and
RequireVerifiedUser. It has no role check yet
Scenario Outline: Role.Allows evaluates scope and ownership
Given a role whose grant for "<key>" is "<scope>"
When Allows("<key>", owned=<owned>) is evaluated
Then the result is <allowed>
Examples:
| key | scope | owned | allowed |
| content.update | all | false | true |
| content.update | all | true | true |
| content.update | own | true | true |
| content.update | own | false | false |
| content.update | none | true | false |
| content.create | all | false | true |
| schema.view | none | false | false |
Scenario: A key absent from the role is not allowed
Given a role with no grants
When Allows("content.read", owned=true) is evaluated
Then the result is false
Scenario: An unknown key is rejected, never silently allowed
Given a role with grants for every catalogue key at all
When Allows("content.explode", owned=true) is evaluated
Then the result is false
And looking the key up in the catalogue returns ErrUnknownPermission (errors.Is)
Scenario: The catalogue is stable and complete
Then it has 34 keys in the order and groups of the Background table
And exactly these 5 keys are owner-only: settings.delete_site, roles.edit_owner_only,
system.apply_updates, system.edit_retention, system.transfer_ownership
And content.create is the only content key of kind B
And the scoped keys are content.read, content.update, content.delete, content.publish, media.delete,
authors.edit, activity.view, apikeys.view, apikeys.revoke
Scenario: Seeding an empty database creates the three built-in roles
Given an empty database after AutoMigrate
When the built-in roles are seeded
Then roles "Owner", "Admin" and "Writer" exist with built_in true and version 1
And only Owner has locked true
And Owner has 34 grants, all at scope all
And Admin has 29 grants, all at scope all, and none of the 5 owner-only keys
And Writer has exactly the 9 grants of the Background table
Scenario: Seeding twice changes nothing
Given a database where the built-in roles are already seeded
When the built-in roles are seeded again
Then there are still exactly 3 roles with the same ids and 72 role_permission rows
Scenario: Seeding never overwrites an edited built-in role
Given a seeded database where the Writer role carries version 4 and an extra grant media.delete=own
When the built-in roles are seeded again
Then the Writer role keeps version 4 and the extra grant
Scenario: Seeding never touches a custom role that reuses a built-in name
Given a custom role named "Writer" with built_in false exists before seeding
When the built-in roles are seeded
Then startup is not aborted and the existing row is left unchanged
And the built-in Writer is still created or found by built_in true
Scenario: Existing owner and admin users get the matching built-in role
Given the built-in roles are seeded
And users 3 (legacy role "owner"), 5 (legacy role "admin") and 9 (legacy role "admin") with no role_id
When BackfillUserRoleIDs runs
Then user 3 has role_id of the Owner role
And users 5 and 9 have role_id of the Admin role
And the legacy role string of every user is unchanged
Scenario: The backfill runs after BackfillUserRoles on a database that predates both
Given users 2 and 4 with legacy role "" and no role_id
When Migrate runs
Then user 2 has legacy role "owner" and the Owner role_id
And user 4 has legacy role "admin" and the Admin role_id
Scenario: The role_id backfill is idempotent and respects an existing role_id
Given user 5 has legacy role "admin" and role_id of a custom role "Contributor"
When BackfillUserRoleIDs runs twice
Then user 5 keeps the role_id of "Contributor"
Scenario: A database with no users is left alone
Given a seeded database with no users
When BackfillUserRoleIDs runs
Then no error is returned
Scenario: Existing articles are attributed to the first owner
Given the owner is user 3 and articles 10 and 11 with created_by 0
When BackfillCreatedBy runs
Then articles 10 and 11 have created_by 3
Scenario: Entries without an author are attributed to the first owner
Given the owner is user 3, entry 20 with created_by 0 and entry 21 with created_by 7
When BackfillCreatedBy runs
Then entry 20 has created_by 3 and entry 21 keeps created_by 7
Scenario: The created_by backfill never rewrites an existing value, and is idempotent
Given article 10 with created_by 7
When BackfillCreatedBy runs twice
Then article 10 keeps created_by 7
Scenario: With no owner yet the backfill does nothing
Given articles with created_by 0 and no users
When BackfillCreatedBy runs
Then no error is returned and every created_by is still 0
Scenario: New articles record their creator
Given user 7 creates an article through CreateArticleUseCase
Then the stored article has created_by 7
Scenario: The owner's /me carries the Owner role and all 34 permissions
Given user 7, verified, legacy role "owner", role_id of the Owner role, valid jwt cookie, token_version 0
When the client sends GET /v1/auth/me with that cookie
Then the response status is 200 and Cache-Control is "no-store"
And the body keeps id, username, email, is_verified, display_name, role "owner" and author
And access.role is {"id": <Owner id>, "name": "Owner", "built_in": true, "locked": true}
And access.permissions lists the 34 catalogue keys in catalogue order, each as {"key", "scope": "all"}
Scenario: An admin's /me omits the owner-only keys
Given user 8, verified, legacy role "admin", role_id of the Admin role, valid jwt cookie
When the client sends GET /v1/auth/me
Then access.role.name is "Admin", built_in true, locked false
And access.permissions has 29 entries, none of them owner-only
And roles.view and roles.manage are present at scope "all"
Scenario: A writer's /me lists only granted keys with their scope
Given user 9, verified, role_id of the Writer role, valid jwt cookie
When the client sends GET /v1/auth/me
Then access.permissions is, in catalogue order:
| key | scope |
| content.read | all |
| content.create | all |
| content.update | all |
| schema.view | all |
| media.view | all |
| media.upload | all |
| authors.view | all |
| authors.edit | own |
| activity.view | own |
Scenario: A user on a custom role sees that role
Given user 10, verified, role_id of a custom role "Contributor" (not built-in, not locked) granting content.read=all and content.update=own
When the client sends GET /v1/auth/me
Then access.role.name is "Contributor", built_in false
And access.permissions is [{"key":"content.read","scope":"all"},{"key":"content.update","scope":"own"}]
Scenario Outline: A user with no role_id falls back to the built-in role named by the legacy role
Given user 11, verified, legacy role "<legacy>", role_id 0, valid jwt cookie
When the client sends GET /v1/auth/me
Then access.role.name is "<name>"
Examples:
| legacy | name |
| owner | Owner |
| admin | Admin |
Scenario: The permissions follow the role live
Given user 9 is on the Writer role, and the Writer role's media.delete grant is then set to own in the repository
When the client sends GET /v1/auth/me again with the same cookie
Then access.permissions now contains {"key":"media.delete","scope":"own"}
Scenario: An unverified user still gets /me, with access
Given user 12, is_verified false, role_id of the Writer role, valid jwt cookie
When the client sends GET /v1/auth/me
Then the response status is 200, is_verified is false and access.role.name is "Writer"
Scenario: A user whose role cannot be resolved gets a server error, not an empty access block
Given user 13, role_id 99 (no such role), legacy role "", valid jwt cookie
When the client sends GET /v1/auth/me
Then the response status is 500 and the body is "Internal server error"
Scenario: The existing /me failures are unchanged
Then no cookie gives 401, a stale token_version gives 401 apperr.UnauthorizedErrorMessage, and a deleted account gives 401 apperr.UnauthorizedErrorMessage, exactly as in features/get_current_user.feature
Scenario: A signed-in verified user reads the catalogue
Given user 9 on the Writer role, verified, valid jwt cookie
When the client sends GET /v1/permission with that cookie
Then the response status is 200
And the body is {"groups":[{"key":"content","items":[...]}, ...]} with the 9 groups in the order content, schema, media, authors, activity, users, settings, roles, system
And each item is {"key": "content.read", "scope_kind": "scoped"|"toggle", "owner_only": bool, "per_type": bool}
And per_type is true only for the 5 content.* items
And scope_kind is "toggle" for on/off keys and "scoped" for none/own/all keys
And the 5 owner-only keys have owner_only true
And the body holds no human-readable names (copy lives in the SPA)
Scenario: The catalogue is the same for every role
Given the Writer and the Owner each request GET /v1/permission
Then both bodies are byte-identical
Scenario: An API key may read the catalogue
Given a valid API key whose creator is verified
When the client sends GET /v1/permission with that key
Then the response status is 200
Scenario: No cookie is refused
When the client sends GET /v1/permission with no jwt cookie
Then the response status is 401
Scenario: A stale token is refused
Given a stored token_version 1 and a cookie encoding 0
When the client sends GET /v1/permission
Then the response status is 401 and the body is apperr.UnauthorizedErrorMessage
Scenario: An unverified user is refused
Given user 12, is_verified false
When the client sends GET /v1/permission
Then the response status is 403 and the body is apperr.VerificationRequiredErrorMessage
Scenario Outline: Existing role checks are untouched in this slice
Given the migration has run and user <id> has legacy role "<legacy>"
When the client calls <call> with a valid session for that user
Then the status is <status> exactly as before the migration
Examples:
| id | legacy | call | status |
| 3 | owner | POST /v1/user/invite | allowed (not 403) |
| 5 | admin | POST /v1/user/invite | 403 apperr.OwnerRequiredErrorMessage |
| 5 | admin | POST /v1/content-type | allowed (not 403) |
| 5 | admin | PATCH /v1/settings | 403 apperr.OwnerRequiredErrorMessage |
| 5 | admin | POST /v1/system/update/check | 403 apperr.OwnerRequiredErrorMessage |
@@ -0,0 +1,57 @@
# QA procedure: roles_permissions_foundation
Package: `test` (black-box). Name new fakes uniquely, for example `mockRoleRepo`, `rolesFoundationUserRepo`.
Do not copy the old `Role ==` checks into new code.
## Deferred to later slices
- 2a: permission-based route and middleware checks, API keys on the creator's live role, admin relaxations, 403 missing_permission, Writer effective.
- 2b: own/all enforcement after loading the item, publish separate from update, Draft and "Ask an editor to publish."
- 3: /v1/role CRUD, reassign, no-escalation, role_locked, role_has_members, role_name_taken, role_conflict, per-type content override keys `content.<action>:<type_key>`, role events.
- 4: PUT /v1/user/{id}/role, invite role (default Writer), transfer ownership, protected owner, removal of the legacy role string.
- 5-7: all SPA states in docs/design/screens/roles.md.
## Design note for the user
The "Owner-only items" state in docs/design/screens/roles.md disables View roles and Manage roles for an admin. This slice follows the user's decision that Admin keeps roles.view and roles.manage (29 of 34 keys), matching the catalogue table. roles.md needs a design fix by the user; no stage edits docs/design.
## Test infrastructure needed first
1. `mockRoleRepo` implementing the role domain `Repository` in memory. Seed it with the three built-ins through the same Go function the migration uses, or assert it matches the Background tables.
2. Extend the user fake with `RoleID`, and the article fake with `CreatedBy`.
3. The persistence tests use an in-memory SQLite DSN (as `TestBackfillUserRoles` in `test/user_roles_test.go`), never a file. Register `models.Role`, `models.RolePermission`, `models.User`, `models.Article` and `models.Entry` via `database.Migrate` where the whole sequence is asserted, and via targeted AutoMigrate for single-backfill tests.
## A. Domain (pure Go, no HTTP, no GORM)
4. Table-test `Role.Allows` with the Examples of "Role.Allows evaluates scope and ownership", plus the absent-key and unknown-key cases. Assert `errors.Is(err, role.ErrUnknownPermission)` for the lookup.
5. Catalogue test: length 34, group order, key order, the 5 owner-only keys, the 9 scoped keys, and `per_type` only for the 5 content keys. A unique-keys check guards against duplicates.
6. A hygiene test that `internal/domain/role` imports only the stdlib (grep the import list, as in the existing `*_hygiene_test.go` files).
## B. Persistence (in-memory SQLite)
7. Seed on an empty DB: assert 3 roles, flags, versions, and the 34 / 29 / 9 grant counts and scopes. Run again: still 3 roles with the same ids and 72 role_permission rows.
8. Edited built-in: bump Writer's `version` to 4 and add `media.delete=own`, seed again, assert untouched.
9. Name collision: pre-insert a custom "Writer" (built_in false), seed, assert no error and the row is unchanged.
10. `BackfillUserRoleIDs`: cases for owner/admin mapping, the legacy string unchanged, an existing `role_id` kept, double run, empty DB.
11. Full `database.Migrate` on a DB created with the old schema (`users.role = ""`, no `role_id`): assert ordering (legacy role first, then role_id).
12. `BackfillCreatedBy`: articles and entries with 0, entries with an existing value, double run, no owner.
13. Portability: assert there is no raw SQL specific to SQLite in the new repositories (code review item); MySQL and PostgreSQL are not exercised here.
## C. Use cases (fakes only)
14. `GetMyAccessUseCase`: owner, admin, writer, custom role, legacy fallback (role_id 0 with "owner" and "admin"), live change after a grant edit, unresolvable role (expect a non-sentinel error).
15. `ListPermissionsUseCase`: returns the domain catalogue unchanged.
16. `CreateArticleUseCase` sets `CreatedBy` to the acting user id.
## D. HTTP (`httptest`, fresh `chi.NewRouter()` mirroring `api/router.go`)
17. Call `validator.InitValidator()`. Use the token helper the current `/me` tests use. Mint cookies as `get_current_user.qa.md` step 7 does.
18. Mount `/v1/auth/me` as in `NewAuthRouter` (Verifier, apiKeyAuth, Authenticator, RequireCurrentToken) with `auth.NewMeHandler(getUser, getMyAuthor, getMyAccess)`. Re-run all `get_current_user` cases unchanged (additive field) and add the access cases: owner, admin, writer, custom, fallback, live change, unverified, unresolvable role (500).
19. Mount `/v1/permission` as in the new `NewPermissionRouter` (adds RequireVerifiedUser). Cases: Writer 200 with the full shape, Owner and Writer bodies equal, API key 200, no cookie 401, stale token 401, unverified 403.
20. "Unchanged behaviour" Outline: build the existing `NewUserRouter`, `NewSettingsRouter`, `NewContentTypeRouter` and `NewSystemRouter` groups over users carrying role_id, and assert the statuses are as before. The simplest source is the existing router tests; add a seeded-role_id variant.
## Manual verification
- Start a dev build (`task dev:api`) on a copy of a database created before this change. Check that the startup migration succeeds, that no data is lost, and that `GET /v1/auth/me` and `GET /v1/permission` respond for the existing owner and admin.
- MySQL and PostgreSQL migration of the new tables is a manual item; the repo has no CI and no automated run for it.
- The SPA is untouched in this slice. SPA states in docs/design/screens/roles.md are informational.
+1
View File
@@ -118,6 +118,7 @@ type Article struct {
Body string `json:"body"`
IsOnline bool `json:"is_online"`
SiteID uint32 `json:"site_id"`
CreatedBy uint32 `json:"-"`
Authors []author.Byline `json:"authors"`
Slug string `json:"slug"`
Excerpt string `json:"excerpt"`
+48
View File
@@ -0,0 +1,48 @@
package role
const (
OwnerName = "Owner"
AdminName = "Admin"
WriterName = "Writer"
)
func allGrants(include func(Permission) bool) []Grant {
grants := []Grant{}
for _, permission := range catalogue {
if include(permission) {
grants = append(grants, Grant{Key: permission.Key, Scope: ScopeAll})
}
}
return grants
}
func writerGrants() []Grant {
return []Grant{
{Key: "content.read", Scope: ScopeAll},
{Key: "content.create", Scope: ScopeAll},
{Key: "content.update", Scope: ScopeAll},
{Key: "schema.view", Scope: ScopeAll},
{Key: "media.view", Scope: ScopeAll},
{Key: "media.upload", Scope: ScopeAll},
{Key: "authors.view", Scope: ScopeAll},
{Key: "authors.edit", Scope: ScopeOwn},
{Key: "activity.view", Scope: ScopeOwn},
}
}
func BuiltInRoles() []Role {
return []Role{
{
Name: OwnerName, BuiltIn: true, Locked: true, Version: 1,
Grants: allGrants(func(Permission) bool { return true }),
},
{
Name: AdminName, BuiltIn: true, Version: 1,
Grants: allGrants(func(p Permission) bool { return !p.OwnerOnly }),
},
{
Name: WriterName, BuiltIn: true, Version: 1,
Grants: writerGrants(),
},
}
}
+118
View File
@@ -0,0 +1,118 @@
package role
import "strings"
type Kind string
const (
KindScoped Kind = "scoped"
KindToggle Kind = "toggle"
)
const contentGroup = "content"
type Permission struct {
Key string
Group string
Kind Kind
OwnerOnly bool
}
func (p Permission) PerType() bool {
return strings.HasPrefix(p.Key, contentGroup+".")
}
func scoped(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindScoped}
}
func toggle(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindToggle}
}
func ownerOnly(group, key string) Permission {
return Permission{Key: key, Group: group, Kind: KindToggle, OwnerOnly: true}
}
var catalogue = []Permission{
scoped("content", "content.read"),
toggle("content", "content.create"),
scoped("content", "content.update"),
scoped("content", "content.delete"),
scoped("content", "content.publish"),
toggle("schema", "schema.view"),
toggle("schema", "schema.edit"),
toggle("schema", "schema.delete"),
toggle("media", "media.view"),
toggle("media", "media.upload"),
scoped("media", "media.delete"),
toggle("authors", "authors.view"),
toggle("authors", "authors.create"),
scoped("authors", "authors.edit"),
toggle("authors", "authors.delete"),
scoped("activity", "activity.view"),
toggle("activity", "activity.export"),
toggle("users", "users.view"),
toggle("users", "users.invite"),
toggle("users", "users.change_role"),
toggle("users", "users.remove"),
toggle("settings", "settings.view"),
toggle("settings", "settings.edit_general"),
toggle("settings", "settings.manage_sites"),
ownerOnly("settings", "settings.delete_site"),
scoped("settings", "apikeys.view"),
toggle("settings", "apikeys.create"),
scoped("settings", "apikeys.revoke"),
toggle("roles", "roles.view"),
toggle("roles", "roles.manage"),
ownerOnly("roles", "roles.edit_owner_only"),
ownerOnly("system", "system.apply_updates"),
ownerOnly("system", "system.edit_retention"),
ownerOnly("system", "system.transfer_ownership"),
}
func Catalogue() []Permission {
return append([]Permission(nil), catalogue...)
}
func Lookup(key string) (Permission, error) {
for _, permission := range catalogue {
if permission.Key == key {
return permission, nil
}
}
return Permission{}, ErrUnknownPermission
}
func Groups() []string {
groups := []string{}
for _, permission := range catalogue {
if len(groups) == 0 || groups[len(groups)-1] != permission.Group {
groups = append(groups, permission.Group)
}
}
return groups
}
const (
typeSeparator = ":"
maxKeyLength = 100
typeKeyAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789_-"
)
func ParseTypedKey(composite string) (string, string, error) {
base, typeKey, found := strings.Cut(composite, typeSeparator)
if !found || len(composite) > maxKeyLength || typeKey == "" {
return "", "", ErrUnknownPermission
}
permission, err := Lookup(base)
if err != nil || !permission.PerType() {
return "", "", ErrUnknownPermission
}
for _, char := range typeKey {
if !strings.ContainsRune(typeKeyAlphabet, char) {
return "", "", ErrUnknownPermission
}
}
return base, typeKey, nil
}
+120
View File
@@ -0,0 +1,120 @@
package role
import (
"errors"
"strings"
)
var (
ErrRoleNotFound = errors.New("role not found")
ErrUnknownPermission = errors.New("unknown permission")
ErrRoleConflict = errors.New("role was modified concurrently")
ErrInvalidScope = errors.New("invalid scope for permission")
ErrOwnerOnlyGrant = errors.New("owner-only permission granted to a non-owner role")
)
type Scope string
const (
ScopeNone Scope = "none"
ScopeOwn Scope = "own"
ScopeAll Scope = "all"
)
type Grant struct {
Key string `json:"key"`
Scope Scope `json:"scope"`
}
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"`
}
func (r Role) scopeOf(key string) Scope {
for _, grant := range r.Grants {
if grant.Key == key {
return grant.Scope
}
}
return ScopeNone
}
func (r Role) Allows(key string, owned bool) bool {
permission, err := Lookup(key)
if err != nil {
return false
}
if permission.OwnerOnly && !r.Locked {
return false
}
return scopeAllows(permission.Kind, r.scopeOf(key), owned)
}
func (r Role) AllowsFor(key string, typeKey string, owned bool) bool {
if typeKey == "" {
return r.Allows(key, owned)
}
composite := key + typeSeparator + typeKey
base, _, err := ParseTypedKey(composite)
if err != nil {
return false
}
permission, _ := Lookup(base)
for _, grant := range r.Grants {
if grant.Key == composite {
return scopeAllows(permission.Kind, grant.Scope, owned)
}
}
return r.Allows(key, owned)
}
func scopeAllows(kind Kind, scope Scope, owned bool) bool {
switch scope {
case ScopeAll:
return true
case ScopeOwn:
return kind == KindScoped && owned
default:
return false
}
}
func resolveGrantKey(key string) (Permission, error) {
if strings.Contains(key, typeSeparator) {
base, _, err := ParseTypedKey(key)
if err != nil {
return Permission{}, err
}
return Lookup(base)
}
return Lookup(key)
}
func ValidateGrants(locked bool, grants []Grant) error {
for _, grant := range grants {
permission, err := resolveGrantKey(grant.Key)
if err != nil {
return err
}
if permission.OwnerOnly && !locked {
return ErrOwnerOnlyGrant
}
if !scopeValidFor(permission.Kind, grant.Scope) {
return ErrInvalidScope
}
}
return nil
}
func scopeValidFor(kind Kind, scope Scope) bool {
if kind == KindToggle {
return scope == ScopeAll
}
return scope == ScopeOwn || scope == ScopeAll
}
@@ -0,0 +1,10 @@
package role
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)
}
+1
View File
@@ -47,6 +47,7 @@ type User struct {
DisplayName string `json:"display_name"`
IsVerified bool `json:"is_verified"`
Role Role `json:"role"`
RoleID uint32 `json:"role_id"`
VerificationCode string `json:"verification_code"`
VerificationExpiration time.Time `json:"verification_expiration"`
PasswordResetExpiration time.Time `json:"password_reset_expiration"`
@@ -0,0 +1,25 @@
package database
import (
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func BackfillCreatedBy(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
var owner entity.User
result := tx.Where("role = ?", string(domainUser.RoleOwner)).Order("id asc").Limit(1).Find(&owner)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
if err := tx.Unscoped().Model(&entity.Article{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error; err != nil {
return err
}
return tx.Model(&entity.Entry{}).Where("created_by = 0").UpdateColumn("created_by", owner.ID).Error
})
}
@@ -7,16 +7,25 @@ import (
)
func Migrate(db *gorm.DB, publicURL string) error {
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{})
err := tableOptionsFor(db).AutoMigrate(&models.Article{}, &models.Image{}, &models.User{}, &models.Invite{}, &models.APIKey{}, &models.PurgeSettings{}, &models.AuditEvent{}, &models.AuditSettings{}, &models.ImageVariant{}, &models.ImageVariantFile{}, &models.ImageVariantRejectionDay{}, &models.Site{}, &models.Author{}, &models.ArticleAuthor{}, &models.ContentType{}, &models.Entry{}, &models.RevokedSession{}, &models.Role{}, &models.RolePermission{})
if err != nil {
return err
}
if err := ensureImagePathIndex(db); err != nil {
return err
}
if err := SeedBuiltInRoles(db); err != nil {
return err
}
if err := BackfillUserRoles(db); err != nil {
return err
}
if err := BackfillUserRoleIDs(db); err != nil {
return err
}
if err := BackfillCreatedBy(db); err != nil {
return err
}
if err := SeedDefaultSite(db, publicURL); err != nil {
return err
}
@@ -0,0 +1,70 @@
package database
import (
"slices"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
func SeedBuiltInRoles(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for _, builtIn := range role.BuiltInRoles() {
if err := seedBuiltInRole(tx, builtIn); err != nil {
return err
}
}
return nil
})
}
func seedBuiltInRole(tx *gorm.DB, builtIn role.Role) error {
id, found, err := builtInRoleID(tx, builtIn.Name)
if err != nil {
return err
}
if found {
return reconcileBuiltInRole(tx, id, builtIn)
}
model := mappers.RoleToModel(builtIn)
if err := tx.Create(&model).Error; err != nil {
return err
}
return tx.Create(mappers.GrantsToModel(model.ID, builtIn.Grants)).Error
}
func builtInRoleID(tx *gorm.DB, name string) (uint32, bool, error) {
var found entity.Role
result := tx.Where("name = ? AND built_in = ?", name, true).Order("id asc").Limit(1).Find(&found)
if result.Error != nil {
return 0, false, result.Error
}
return found.ID, result.RowsAffected > 0, nil
}
func reconcileBuiltInRole(tx *gorm.DB, id uint32, builtIn role.Role) error {
var stored entity.Role
if err := tx.First(&stored, id).Error; err != nil {
return err
}
if !builtIn.Locked && stored.Version != 1 {
return nil
}
var present []string
if err := tx.Model(&entity.RolePermission{}).Where("role_id = ?", id).Pluck("permission_key", &present).Error; err != nil {
return err
}
missing := []role.Grant{}
for _, grant := range builtIn.Grants {
if !slices.Contains(present, grant.Key) {
missing = append(missing, grant)
}
}
if len(missing) == 0 {
return nil
}
return tx.Create(mappers.GrantsToModel(id, missing)).Error
}
@@ -0,0 +1,35 @@
package database
import (
"RenewCMS/internal/domain/role"
domainUser "RenewCMS/internal/domain/user"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
var legacyRoleNames = map[domainUser.Role]string{
domainUser.RoleOwner: role.OwnerName,
domainUser.RoleAdmin: role.AdminName,
}
func BackfillUserRoleIDs(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for legacy, name := range legacyRoleNames {
roleID, found, err := builtInRoleID(tx, name)
if err != nil {
return err
}
if !found {
continue
}
err = tx.Model(&entity.User{}).
Where("role = ? AND (role_id IS NULL OR role_id = 0)", string(legacy)).
UpdateColumn("role_id", roleID).Error
if err != nil {
return err
}
}
return nil
})
}
@@ -37,6 +37,7 @@ func ArticleToDomain(m models.Article) article.Article {
Body: m.Body,
IsOnline: m.IsOnline,
SiteID: m.SiteID,
CreatedBy: m.CreatedBy,
Slug: m.Slug,
Excerpt: m.Excerpt,
SEOTitle: m.SEOTitle,
@@ -60,6 +61,7 @@ func ArticleToModel(a article.Article) models.Article {
Body: a.Body,
IsOnline: a.IsOnline,
SiteID: a.SiteID,
CreatedBy: a.CreatedBy,
Slug: a.Slug,
Excerpt: a.Excerpt,
SEOTitle: a.SEOTitle,
@@ -0,0 +1,45 @@
package mappers
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/models"
)
func RoleToDomain(m models.Role, grants []models.RolePermission) role.Role {
return role.Role{
ID: m.ID,
Name: m.Name,
Description: m.Description,
BuiltIn: m.BuiltIn,
Locked: m.Locked,
Version: m.Version,
Grants: GrantsToDomain(grants),
}
}
func RoleToModel(r role.Role) models.Role {
return models.Role{
ID: r.ID,
Name: r.Name,
Description: r.Description,
BuiltIn: r.BuiltIn,
Locked: r.Locked,
Version: r.Version,
}
}
func GrantsToDomain(rows []models.RolePermission) []role.Grant {
grants := make([]role.Grant, 0, len(rows))
for _, row := range rows {
grants = append(grants, role.Grant{Key: row.Key, Scope: role.Scope(row.Scope)})
}
return grants
}
func GrantsToModel(roleID uint32, grants []role.Grant) []models.RolePermission {
rows := make([]models.RolePermission, 0, len(grants))
for _, grant := range grants {
rows = append(rows, models.RolePermission{RoleID: roleID, Key: grant.Key, Scope: string(grant.Scope)})
}
return rows
}
@@ -19,6 +19,20 @@ func DomainToNullableString(value string) *string {
return &value
}
func nullableIDToDomain(value *uint32) uint32 {
if value == nil {
return 0
}
return *value
}
func domainToNullableID(value uint32) *uint32 {
if value == 0 {
return nil
}
return &value
}
func UserToDomain(m models.User) user.User {
return user.User{
ID: m.ID,
@@ -29,6 +43,7 @@ func UserToDomain(m models.User) user.User {
DisplayName: m.DisplayName,
IsVerified: m.IsVerified,
Role: user.Role(m.Role),
RoleID: nullableIDToDomain(m.RoleID),
VerificationCode: nullableStringToDomain(m.VerificationCode),
VerificationExpiration: m.VerificationExpiration,
PasswordResetExpiration: m.PasswordResetExpiration,
@@ -47,6 +62,7 @@ func UserToModel(a user.User) models.User {
DisplayName: a.DisplayName,
IsVerified: a.IsVerified,
Role: string(a.Role),
RoleID: domainToNullableID(a.RoleID),
VerificationCode: DomainToNullableString(a.VerificationCode),
VerificationExpiration: a.VerificationExpiration,
PasswordResetExpiration: a.PasswordResetExpiration,
@@ -13,6 +13,7 @@ type Article struct {
Body string
IsOnline bool `gorm:"not_null;default:false"`
SiteID uint32 `gorm:"index"`
CreatedBy uint32 `gorm:"not null;default:0"`
Slug string `gorm:"size:100;not null;default:''"`
Excerpt string `gorm:"size:300;not null;default:''"`
SEOTitle string `gorm:"column:seo_title;size:120;not null;default:''"`
@@ -0,0 +1,17 @@
package models
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"`
}
type RolePermission struct {
ID uint32 `gorm:"primaryKey;autoIncrement"`
RoleID uint32 `gorm:"not null;uniqueIndex:idx_role_permission_key"`
Key string `gorm:"column:permission_key;not null;size:100;uniqueIndex:idx_role_permission_key"`
Scope string `gorm:"not null;size:8"`
}
@@ -13,6 +13,7 @@ type User struct {
DisplayName string `gorm:"not null;default:'';size:255"`
IsVerified bool `gorm:"default=false;not null"`
Role string `gorm:"not null;default:'';size:32"`
RoleID *uint32 `gorm:"index"`
VerificationCode *string `gorm:"unique;size:255"`
VerificationExpiration time.Time
PasswordResetExpiration time.Time
@@ -0,0 +1,119 @@
package persistence
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/mappers"
entity "RenewCMS/internal/infrastructure/persistence/models"
"gorm.io/gorm"
)
type RoleRepository struct {
db *gorm.DB
}
func NewRoleRepository(db *gorm.DB) *RoleRepository {
return &RoleRepository{db}
}
func (r *RoleRepository) grantRows(roleID uint32) ([]entity.RolePermission, error) {
var rows []entity.RolePermission
if err := r.db.Where("role_id = ?", roleID).Order("id asc").Find(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *RoleRepository) withGrants(found entity.Role) (role.Role, error) {
rows, err := r.grantRows(found.ID)
if err != nil {
return role.Role{}, err
}
return mappers.RoleToDomain(found, rows), nil
}
func (r *RoleRepository) List() ([]role.Role, error) {
var found []entity.Role
if err := r.db.Order("id asc").Find(&found).Error; err != nil {
return nil, err
}
roles := make([]role.Role, 0, len(found))
for _, row := range found {
roles = append(roles, mappers.RoleToDomain(row, nil))
}
return roles, nil
}
func (r *RoleRepository) Get(id uint32) (role.Role, error) {
var found entity.Role
if err := r.db.First(&found, id).Error; err != nil {
return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound)
}
return r.withGrants(found)
}
func (r *RoleRepository) GetByName(name string) (role.Role, error) {
var found entity.Role
if err := r.db.Where("name = ?", name).Order("built_in DESC, id ASC").First(&found).Error; err != nil {
return role.Role{}, mapNotFoundErr(err, role.ErrRoleNotFound)
}
return r.withGrants(found)
}
func (r *RoleRepository) ListGrants(roleID uint32) ([]role.Grant, error) {
rows, err := r.grantRows(roleID)
if err != nil {
return nil, err
}
return mappers.GrantsToDomain(rows), 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 := tx.Create(&model).Error; err != nil {
return err
}
rows := mappers.GrantsToModel(model.ID, created.Grants)
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
if err != nil {
return role.Role{}, err
}
return r.withGrants(model)
}
func (r *RoleRepository) Save(saved role.Role) (role.Role, error) {
err := r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.First(&entity.Role{}, saved.ID).Error; err != nil {
return mapNotFoundErr(err, role.ErrRoleNotFound)
}
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})
if update.Error != nil {
return update.Error
}
if update.RowsAffected == 0 {
return role.ErrRoleConflict
}
if err := tx.Where("role_id = ?", saved.ID).Delete(&entity.RolePermission{}).Error; err != nil {
return err
}
rows := mappers.GrantsToModel(saved.ID, saved.Grants)
if len(rows) == 0 {
return nil
}
return tx.Create(&rows).Error
})
if err != nil {
return role.Role{}, err
}
return r.Get(saved.ID)
}
var _ role.Repository = &RoleRepository{}
@@ -71,6 +71,7 @@ func (g *CreateArticleUseCase) CreateArticle(ctx context.Context, createArticle
Body: createArticle.Body,
IsOnline: false,
SiteID: siteID,
CreatedBy: createArticle.CreatedBy,
Authors: creditedAuthors,
SEODismissed: []string{},
})
@@ -0,0 +1,69 @@
package useCases
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
)
type Access struct {
Role role.Role
Permissions []role.Grant
}
type GetMyAccessUseCase struct {
roles role.Repository
}
func NewGetMyAccessUseCase(roles role.Repository) *GetMyAccessUseCase {
return &GetMyAccessUseCase{roles: roles}
}
var legacyBuiltInNames = map[user.Role]string{
user.RoleOwner: role.OwnerName,
user.RoleAdmin: role.AdminName,
}
func (u *GetMyAccessUseCase) resolveRole(account user.User) (role.Role, error) {
if account.RoleID != 0 {
return u.roles.Get(account.RoleID)
}
name, known := legacyBuiltInNames[account.Role]
if !known {
return role.Role{}, role.ErrRoleNotFound
}
found, err := u.roles.GetByName(name)
if err != nil {
return role.Role{}, err
}
if !found.BuiltIn {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
}
func (u *GetMyAccessUseCase) Get(account user.User) (Access, error) {
resolved, err := u.resolveRole(account)
if err != nil {
return Access{}, err
}
granted, err := u.roles.ListGrants(resolved.ID)
if err != nil {
return Access{}, err
}
return Access{Role: resolved, Permissions: orderedGrants(granted)}, nil
}
func orderedGrants(granted []role.Grant) []role.Grant {
scopes := make(map[string]role.Scope, len(granted))
for _, grant := range granted {
scopes[grant.Key] = grant.Scope
}
ordered := []role.Grant{}
for _, permission := range role.Catalogue() {
scope, present := scopes[permission.Key]
if present && scope != role.ScopeNone {
ordered = append(ordered, role.Grant{Key: permission.Key, Scope: scope})
}
}
return ordered
}
@@ -0,0 +1,27 @@
package useCases
import "RenewCMS/internal/domain/role"
type PermissionGroup struct {
Key string
Items []role.Permission
}
type ListPermissionsUseCase struct{}
func NewListPermissionsUseCase() *ListPermissionsUseCase {
return &ListPermissionsUseCase{}
}
func (u *ListPermissionsUseCase) List() []PermissionGroup {
groups := []PermissionGroup{}
for _, permission := range role.Catalogue() {
last := len(groups) - 1
if last < 0 || groups[last].Key != permission.Group {
groups = append(groups, PermissionGroup{Key: permission.Group})
last++
}
groups[last].Items = append(groups[last].Items, permission)
}
return groups
}
+1 -1
View File
@@ -59,7 +59,7 @@ func newAcceptInviteFixture(t *testing.T, maxAttempts int) *acceptInviteFixture
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
listHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, f.hasher, f.mail, invitePublicURL, discardRecorder),
nil,
+1
View File
@@ -74,6 +74,7 @@ func newActivityHTTPEnv(t *testing.T, attempts int) *activityHTTPEnv {
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(e.users, hasher, h.recorder),
RequestPasswordResetUseCase: useCases.NewRequestPasswordResetUseCase(e.users, hasher, e.mail, "https://cms.example.com", h.recorder),
ResetPasswordUseCase: useCases.NewResetPasswordUseCase(e.users, hasher, e.keys, resetClock, h.recorder),
+1
View File
@@ -64,6 +64,7 @@ var routesNotYetInTheCatalogue = []string{
"GET /v1/image-variants/cache",
"GET /v1/media/{id}/variants",
"GET /v1/user/lookup",
"GET /v1/permission",
"PATCH /v1/image-variants/{name}",
"POST /v1/content-type",
"POST /v1/content-type/{key}/entry",
+4
View File
@@ -45,6 +45,7 @@ var apiKeyRawPattern = regexp.MustCompile(`^rcms_[A-Za-z0-9_-]{43}$`)
type apiKeyEnv struct {
users *mockUserRepo
roles *mockRoleRepo
keys *mockAPIKeyRepo
articles *mockArticleRepo
images *mockImageRepo
@@ -72,6 +73,7 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
env := &apiKeyEnv{
users: newMockUserRepo(),
roles: newSeededMockRoleRepo(),
keys: newMockAPIKeyRepo(),
articles: newMockArticleRepo(),
images: newMockImageRepo(),
@@ -113,6 +115,8 @@ func newAPIKeyEnvRecording(t *testing.T, recorder audit.Recorder) *apiKeyEnv {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, recorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(env.roles),
ListPermissionsUseCase: useCases.NewListPermissionsUseCase(),
CreateUserUseCase: useCases.NewCreateUserUseCase(env.users, hasher, mail, "https://cms.example.com", recorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(env.users, hasher, recorder),
VerifyUserUseCase: useCases.NewVerifyUserUseCase(env.users, hasher, recorder),
+4 -4
View File
@@ -351,8 +351,8 @@ type authorEnv struct {
func newAuthorEnv(t *testing.T) *authorEnv {
t.Helper()
p := newProducersEnv(t)
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole}
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole}
p.users.users[authorEditorID] = user.User{ID: authorEditorID, Username: "edith", DisplayName: "Edith Moreau", Email: "edith@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
p.users.users[authorOtherEditorID] = user.User{ID: authorOtherEditorID, Username: "frank", Email: "frank@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
p.users.nextID = authorOtherEditorID
authors := newMockAuthorRepo(p.articles)
sites := plainSites()
@@ -437,9 +437,9 @@ func joinNames(names []string) string {
}
func userWithEmail(id uint32, username string, email string) user.User {
return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole}
return user.User{ID: id, Username: username, Email: email, IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
}
func userNamed(id uint32, username string, displayName string) user.User {
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole}
return user.User{ID: id, Username: username, DisplayName: displayName, Email: username + "@example.com", IsVerified: true, Role: authorEditorRole, RoleID: writerRoleID}
}
@@ -58,6 +58,7 @@ func newContainerHarness(t *testing.T, container bool, helperInstalled bool) *co
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, helper, rig.results, rig.requests, updateRunning, container, now),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: start,
+7 -5
View File
@@ -19,7 +19,7 @@ import (
func newGetCurrentUserRouter(userRepo *mockUserRepo, hasher *stubHasher, token *authtoken.Auth) *chi.Mux {
getUserUseCase := useCases.NewGetUserUseCase(userRepo)
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()))
meHandler := auth.NewMeHandler(getUserUseCase, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
verificationHandler := auth.NewVerificationHandler(
useCases.NewVerifyUserUseCase(userRepo, hasher, discardRecorder),
useCases.NewCancelRegistrationUseCase(userRepo),
@@ -67,6 +67,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
userRepo.users[7] = user.User{
ID: 7,
Username: "alice",
Role: user.RoleOwner,
Email: "alice@example.com",
IsVerified: true,
TokenVersion: 0,
@@ -97,6 +98,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
userRepo.users[8] = user.User{
ID: 8,
Username: "bob",
Role: user.RoleOwner,
Email: "bob@example.com",
IsVerified: false,
TokenVersion: 0,
@@ -124,7 +126,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("No jwt cookie at all is refused", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
rec := sendGetCurrentUserRequest(router, http.MethodGet, "/v1/auth/me", nil)
@@ -137,7 +139,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("A jwt whose token_version no longer matches the stored user is refused", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 1}
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 1}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
@@ -154,7 +156,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("A jwt minted for a since-deleted account is refused", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: false, TokenVersion: 0}
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: false, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
@@ -175,7 +177,7 @@ func TestGetCurrentUserHttp(t *testing.T) {
t.Run("The route is never rate-limited, unlike login and password reset", func(t *testing.T) {
token := authtoken.New([]byte("test-secret"))
userRepo := newMockUserRepo()
userRepo.users[7] = user.User{ID: 7, Username: "alice", IsVerified: true, TokenVersion: 0}
userRepo.users[7] = user.User{ID: 7, Username: "alice", Role: user.RoleAdmin, IsVerified: true, TokenVersion: 0}
router := newGetCurrentUserRouter(userRepo, &stubHasher{}, token)
cookie := mintGetCurrentUserCookie(t, token, 7, 0)
+1
View File
@@ -114,6 +114,7 @@ func (e *variantEnv) container(limits useCases.ServeVariantLimits) *api.UseCases
Token: e.token,
GetUserUseCase: useCases.NewGetUserUseCase(e.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
OpenMediaUseCase: useCases.NewOpenMediaUseCase(e.storage),
DeleteMediaUseCase: useCases.NewDeleteMediaUseCase(e.images, e.storage, e.files, recorder, plainSites(), plainAuthors(), e.articles),
MergeMediaUseCase: newTestMergeMedia(e.images, e.articles, e.storage, e.files, recorder, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+1
View File
@@ -74,6 +74,7 @@ func newMediaEnv(t *testing.T) *mediaEnv {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(env.articles, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(env.users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
CreateImageUseCase: useCases.NewCreateImageUseCase(env.images, env.storage, discardRecorder, imaging.NewMetadataStripper()),
GenerateVariantsOnUploadUseCase: newInertGenerateOnUploadUseCase(),
ListMediaUseCase: useCases.NewListMediaUseCase(env.images, env.articles, env.users, env.variants, plainSites(), plainAuthors(), newMockContentTypeRepo(), newMockEntryRepo()),
+25
View File
@@ -0,0 +1,25 @@
package test
import (
"path/filepath"
"strings"
"testing"
)
func TestRoleDomainImportsOnlyTheStandardLibrary(t *testing.T) {
t.Run("The role domain imports only the standard library", func(t *testing.T) {
files, err := filepath.Glob("../internal/domain/role/*.go")
if err != nil || len(files) == 0 {
t.Fatalf("no role domain files: %v", err)
}
for _, file := range files {
relative := strings.TrimPrefix(filepath.ToSlash(file), "../")
for _, imported := range importsOf(t, relative) {
first := strings.Split(imported, "/")[0]
if strings.Contains(first, ".") || first == "RenewCMS" {
t.Errorf("%s imports %s", relative, imported)
}
}
}
})
}
+296
View File
@@ -0,0 +1,296 @@
package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"strings"
"testing"
"gorm.io/gorm"
)
func deleteGrantRow(t *testing.T, db *gorm.DB, roleID uint32, key string) {
t.Helper()
if err := db.Where("role_id = ? AND permission_key = ?", roleID, key).Delete(&models.RolePermission{}).Error; err != nil {
t.Fatal(err)
}
}
func TestBuiltInReconciliation(t *testing.T) {
reseed := func(t *testing.T, db *gorm.DB) {
t.Helper()
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatal(err)
}
}
t.Run("Owner regains a missing key at scope all without a version bump", func(t *testing.T) {
db := openMigratedRolesDB(t)
owner := storedRole(t, db, "Owner")
deleteGrantRow(t, db, owner.ID, "system.apply_updates")
deleteGrantRow(t, db, owner.ID, "content.read")
reseed(t, db)
after := storedRole(t, db, "Owner")
scopes := grantMap(after.Grants)
if scopes["system.apply_updates"] != role.ScopeAll || scopes["content.read"] != role.ScopeAll {
t.Errorf("owner did not regain keys")
}
if after.Version != owner.Version || len(after.Grants) != len(owner.Grants) {
t.Errorf("version or grant count changed: %+v", after.Version)
}
})
t.Run("Owner keeps an existing grant untouched", func(t *testing.T) {
db := openMigratedRolesDB(t)
owner := storedRole(t, db, "Owner")
if err := db.Model(&models.RolePermission{}).Where("role_id = ? AND permission_key = ?", owner.ID, "content.read").Update("scope", "own").Error; err != nil {
t.Fatal(err)
}
reseed(t, db)
if grantMap(storedRole(t, db, "Owner").Grants)["content.read"] != role.ScopeOwn {
t.Errorf("existing grant downgraded or overwritten")
}
})
t.Run("An unedited Admin regains a deleted grant but never an owner-only one", func(t *testing.T) {
db := openMigratedRolesDB(t)
admin := storedRole(t, db, "Admin")
deleteGrantRow(t, db, admin.ID, "users.invite")
reseed(t, db)
scopes := grantMap(storedRole(t, db, "Admin").Grants)
if scopes["users.invite"] != role.ScopeAll {
t.Errorf("admin did not regain users.invite")
}
if _, owned := scopes["system.apply_updates"]; owned {
t.Errorf("admin gained an owner-only key")
}
})
t.Run("An edited Writer does not regain a deleted grant", func(t *testing.T) {
db := openMigratedRolesDB(t)
writer := storedRole(t, db, "Writer")
if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 2).Error; err != nil {
t.Fatal(err)
}
deleteGrantRow(t, db, writer.ID, "media.upload")
reseed(t, db)
after := storedRole(t, db, "Writer")
if _, back := grantMap(after.Grants)["media.upload"]; back || after.Version != 2 {
t.Errorf("edited writer reconciled: %+v", after)
}
})
t.Run("An unedited Writer regains a deleted grant", func(t *testing.T) {
db := openMigratedRolesDB(t)
writer := storedRole(t, db, "Writer")
deleteGrantRow(t, db, writer.ID, "media.upload")
reseed(t, db)
if grantMap(storedRole(t, db, "Writer").Grants)["media.upload"] != role.ScopeAll {
t.Errorf("writer did not regain media.upload")
}
})
}
func TestRoleRepositorySaveVersionCheck(t *testing.T) {
t.Run("A stale version is a conflict and leaves grants untouched", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer := storedRole(t, db, "Writer")
before := len(writer.Grants)
writer.Version = 7
writer.Name = "Renamed"
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
if _, err := repo.Save(writer); !errors.Is(err, role.ErrRoleConflict) {
t.Fatalf("want ErrRoleConflict, got %v", err)
}
after := storedRole(t, db, "Writer")
if len(after.Grants) != before || after.Version != 1 {
t.Errorf("stale save changed the role: %+v", after)
}
})
t.Run("A successful save bumps the version and replaces grants", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer := storedRole(t, db, "Writer")
writer.Description = "edited"
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
saved, err := repo.Save(writer)
if err != nil {
t.Fatal(err)
}
if saved.Version != writer.Version+1 || saved.Description != "edited" || len(saved.Grants) != 1 {
t.Errorf("saved: %+v", saved)
}
again := writer
if _, err := repo.Save(again); !errors.Is(err, role.ErrRoleConflict) {
t.Errorf("reusing the old version must conflict, got %v", err)
}
})
t.Run("Save cannot flip BuiltIn or Locked", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer := storedRole(t, db, "Writer")
writer.BuiltIn = false
writer.Locked = true
saved, err := repo.Save(writer)
if err != nil {
t.Fatal(err)
}
if !saved.BuiltIn || saved.Locked {
t.Errorf("flags changed: %+v", saved)
}
})
}
func TestMockRoleRepoSaveMirrorsVersionCheck(t *testing.T) {
repo := newSeededMockRoleRepo()
writer := repo.byName("Writer")
stale := writer
stale.Version = 9
if _, err := repo.Save(stale); !errors.Is(err, role.ErrRoleConflict) {
t.Errorf("want conflict, got %v", err)
}
writer.BuiltIn = false
saved, err := repo.Save(writer)
if err != nil || saved.Version != 2 || !saved.BuiltIn {
t.Errorf("saved %+v err %v", saved, err)
}
}
func TestValidateGrants(t *testing.T) {
cases := []struct {
name string
locked bool
grants []role.Grant
want error
}{
{"valid scoped and toggle", false, []role.Grant{{Key: "content.read", Scope: role.ScopeOwn}, {Key: "schema.view", Scope: role.ScopeAll}}, nil},
{"unknown key", false, []role.Grant{{Key: "content.explode", Scope: role.ScopeAll}}, role.ErrUnknownPermission},
{"scope none stored", false, []role.Grant{{Key: "content.read", Scope: role.ScopeNone}}, role.ErrInvalidScope},
{"unknown scope", false, []role.Grant{{Key: "content.read", Scope: "some"}}, role.ErrInvalidScope},
{"toggle with own", false, []role.Grant{{Key: "schema.view", Scope: role.ScopeOwn}}, role.ErrInvalidScope},
{"owner-only on regular role", false, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, role.ErrOwnerOnlyGrant},
{"owner-only on locked owner", true, []role.Grant{{Key: "system.apply_updates", Scope: role.ScopeAll}}, nil},
{"per-type key", false, []role.Grant{{Key: "content.read:blog", Scope: role.ScopeOwn}}, nil},
{"malformed per-type key", false, []role.Grant{{Key: "content.read:Bad Type", Scope: role.ScopeAll}}, role.ErrUnknownPermission},
{"per-type toggle with own", false, []role.Grant{{Key: "content.create:blog", Scope: role.ScopeOwn}}, role.ErrInvalidScope},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := role.ValidateGrants(c.locked, c.grants); !errors.Is(err, c.want) {
t.Errorf("want %v, got %v", c.want, err)
}
})
}
t.Run("Built-in definitions are valid", func(t *testing.T) {
for _, builtIn := range role.BuiltInRoles() {
if err := role.ValidateGrants(builtIn.Locked, builtIn.Grants); err != nil {
t.Errorf("%s: %v", builtIn.Name, err)
}
}
})
}
func TestRoleAllowsKindAndOwnerOnly(t *testing.T) {
t.Run("A toggle grant at scope own does not count", func(t *testing.T) {
if roleWithGrant("schema.view", role.ScopeOwn).Allows("schema.view", true) {
t.Errorf("expected false")
}
})
t.Run("Owner-only grants are ignored on a role that is not locked", func(t *testing.T) {
if roleWithGrant("system.apply_updates", role.ScopeAll).Allows("system.apply_updates", true) {
t.Errorf("expected false")
}
})
t.Run("Owner-only grants count on the locked Owner", func(t *testing.T) {
if !role.BuiltInRoles()[0].Allows("system.apply_updates", false) {
t.Errorf("expected true")
}
})
}
func TestParseTypedKey(t *testing.T) {
t.Run("A valid composite key is split", func(t *testing.T) {
base, typeKey, err := role.ParseTypedKey("content.update:blog_post-2")
if err != nil || base != "content.update" || typeKey != "blog_post-2" {
t.Errorf("got %q %q %v", base, typeKey, err)
}
})
rejected := map[string]string{
"no separator": "content.read",
"empty type key": "content.read:",
"non per-type key": "schema.view:blog",
"unknown base": "content.explode:blog",
"uppercase": "content.read:Blog",
"space": "content.read:a b",
"second colon": "content.read:a:b",
"too long": "content.read:" + strings.Repeat("a", 100),
}
for name, composite := range rejected {
t.Run("Rejects "+name, func(t *testing.T) {
if _, _, err := role.ParseTypedKey(composite); !errors.Is(err, role.ErrUnknownPermission) {
t.Errorf("want ErrUnknownPermission, got %v", err)
}
})
}
t.Run("Accepts a composite key of exactly 100 characters", func(t *testing.T) {
composite := "content.read:" + strings.Repeat("a", 87)
if _, _, err := role.ParseTypedKey(composite); err != nil {
t.Errorf("unexpected %v", err)
}
})
}
func TestRoleAllowsFor(t *testing.T) {
t.Run("A per-type override denies despite the wildcard", func(t *testing.T) {
r := role.Role{Grants: []role.Grant{{Key: "content.update", Scope: role.ScopeAll}, {Key: "content.update:blog", Scope: role.ScopeOwn}}}
if r.AllowsFor("content.update", "blog", false) {
t.Errorf("override should deny a non-owned entry")
}
if !r.AllowsFor("content.update", "blog", true) {
t.Errorf("override should allow an owned entry")
}
if !r.AllowsFor("content.update", "news", false) {
t.Errorf("other types fall back to the wildcard")
}
})
t.Run("A per-type override grants without a wildcard", func(t *testing.T) {
r := role.Role{Grants: []role.Grant{{Key: "content.read:blog", Scope: role.ScopeAll}}}
if !r.AllowsFor("content.read", "blog", false) || r.AllowsFor("content.read", "news", false) {
t.Errorf("override should apply to blog only")
}
})
t.Run("An empty type key behaves like Allows", func(t *testing.T) {
r := roleWithGrant("content.read", role.ScopeAll)
if !r.AllowsFor("content.read", "", false) {
t.Errorf("expected true")
}
})
t.Run("A malformed type key or non per-type key is denied", func(t *testing.T) {
r := role.BuiltInRoles()[0]
if r.AllowsFor("content.read", "Bad Key", true) || r.AllowsFor("schema.view", "blog", true) {
t.Errorf("expected false")
}
})
}
func TestBackfillCreatedByWithSoftDeletedArticle(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createArticleRow(t, db, 10, 0)
if err := db.Delete(&models.Article{}, 10).Error; err != nil {
t.Fatal(err)
}
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
if createdByOf(t, db, "articles", 10) != 3 {
t.Errorf("soft-deleted article not attributed")
}
}
+134
View File
@@ -0,0 +1,134 @@
package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"errors"
"testing"
)
func TestRoleRepositoryFailures(t *testing.T) {
broken := func(t *testing.T, table string) *persistence.RoleRepository {
t.Helper()
db := openMigratedRolesDB(t)
if err := db.Migrator().DropTable(table); err != nil {
t.Fatal(err)
}
return persistence.NewRoleRepository(db)
}
t.Run("List fails when the roles table is missing", func(t *testing.T) {
if _, err := broken(t, "roles").List(); err == nil {
t.Error("expected error")
}
})
t.Run("Get distinguishes a missing role from a storage failure", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
if _, err := repo.Get(9999); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("want ErrRoleNotFound, got %v", err)
}
if _, err := broken(t, "roles").Get(1); err == nil || errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("want storage error, got %v", err)
}
})
t.Run("GetByName reports a missing role", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
if _, err := repo.GetByName("Nobody"); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("want ErrRoleNotFound, got %v", err)
}
})
t.Run("ListGrants 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.Get(1); err == nil {
t.Error("Get: expected error")
}
})
t.Run("Create persists grants and returns them", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
created, err := repo.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{{Key: "content.read", Scope: role.ScopeAll}}})
if err != nil {
t.Fatal(err)
}
if len(created.Grants) != 1 || created.Grants[0].Key != "content.read" {
t.Errorf("returned grants: %+v", created.Grants)
}
if countRows(t, db, &models.RolePermission{}) != 73 {
t.Errorf("grant rows not stored")
}
})
t.Run("Create rolls back when the grants cannot be stored", func(t *testing.T) {
db := openMigratedRolesDB(t)
if err := db.Migrator().DropTable("role_permissions"); err != nil {
t.Fatal(err)
}
repo := persistence.NewRoleRepository(db)
before := countRows(t, db, &models.Role{})
_, err := repo.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{{Key: "content.read", Scope: role.ScopeAll}}})
if err == nil {
t.Fatal("expected error")
}
if countRows(t, db, &models.Role{}) != before {
t.Errorf("role row not rolled back")
}
})
t.Run("Create fails when the roles table is missing", func(t *testing.T) {
if _, err := broken(t, "roles").Create(role.Role{Name: "X", Version: 1}); err == nil {
t.Error("expected error")
}
})
t.Run("Save returns the stored role with its new grants", func(t *testing.T) {
db := openMigratedRolesDB(t)
repo := persistence.NewRoleRepository(db)
writer := storedRole(t, db, "Writer")
writer.Grants = []role.Grant{{Key: "media.delete", Scope: role.ScopeOwn}}
saved, err := repo.Save(writer)
if err != nil {
t.Fatal(err)
}
if saved.ID != writer.ID || len(saved.Grants) != 1 || saved.Grants[0].Key != "media.delete" {
t.Errorf("saved: %+v", saved)
}
})
t.Run("Save reports a missing role and storage failures", func(t *testing.T) {
repo := persistence.NewRoleRepository(openMigratedRolesDB(t))
if _, err := repo.Save(role.Role{ID: 9999, Name: "Ghost", Version: 1}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("want ErrRoleNotFound, got %v", err)
}
db := openMigratedRolesDB(t)
writer := storedRole(t, db, "Writer")
if err := db.Migrator().DropTable("role_permissions"); err != nil {
t.Fatal(err)
}
if _, err := persistence.NewRoleRepository(db).Save(writer); err == nil {
t.Error("expected storage error")
}
})
}
func TestBackfillUserRoleIDsWithoutBuiltInRoles(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}, &models.User{}); err != nil {
t.Fatal(err)
}
createUserRow(t, db, 1, "owner", nil)
if err := database.BackfillUserRoleIDs(db); err != nil {
t.Fatal(err)
}
if userRow(t, db, 1).RoleID != nil {
t.Errorf("role_id must stay unset when no built-in role exists")
}
}
+125
View File
@@ -0,0 +1,125 @@
package test
import (
"RenewCMS/internal/domain/role"
"slices"
)
const (
ownerRoleID uint32 = 1
adminRoleID uint32 = 2
writerRoleID uint32 = 3
)
type mockRoleRepo struct {
roles map[uint32]role.Role
nextID uint32
fail failSwitch
}
func newMockRoleRepo() *mockRoleRepo {
return &mockRoleRepo{roles: map[uint32]role.Role{}}
}
func newSeededMockRoleRepo() *mockRoleRepo {
repo := newMockRoleRepo()
for _, builtIn := range role.BuiltInRoles() {
_, _ = repo.Create(builtIn)
}
return repo
}
func (m *mockRoleRepo) byName(name string) role.Role {
found, _ := m.GetByName(name)
return found
}
func (m *mockRoleRepo) sortedIDs() []uint32 {
ids := make([]uint32, 0, len(m.roles))
for id := range m.roles {
ids = append(ids, id)
}
slices.Sort(ids)
return ids
}
func (m *mockRoleRepo) List() ([]role.Role, error) {
if err := m.fail.trigger("List"); err != nil {
return nil, err
}
listed := []role.Role{}
for _, id := range m.sortedIDs() {
listed = append(listed, m.roles[id])
}
return listed, nil
}
func (m *mockRoleRepo) Get(id uint32) (role.Role, error) {
if err := m.fail.trigger("Get"); err != nil {
return role.Role{}, err
}
found, ok := m.roles[id]
if !ok {
return role.Role{}, role.ErrRoleNotFound
}
return found, nil
}
func (m *mockRoleRepo) GetByName(name string) (role.Role, error) {
if err := m.fail.trigger("GetByName"); err != nil {
return role.Role{}, err
}
var match *role.Role
for _, id := range m.sortedIDs() {
candidate := m.roles[id]
if candidate.Name == name && (match == nil || candidate.BuiltIn && !match.BuiltIn) {
match = &candidate
}
}
if match == nil {
return role.Role{}, role.ErrRoleNotFound
}
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) {
if err := m.fail.trigger("Create"); err != nil {
return role.Role{}, err
}
m.nextID++
created.ID = m.nextID
m.roles[created.ID] = created
return created, nil
}
func (m *mockRoleRepo) Save(saved role.Role) (role.Role, error) {
if err := m.fail.trigger("Save"); err != nil {
return role.Role{}, err
}
stored, ok := m.roles[saved.ID]
if !ok {
return role.Role{}, role.ErrRoleNotFound
}
if stored.Version != saved.Version {
return role.Role{}, role.ErrRoleConflict
}
stored.Name = saved.Name
stored.Description = saved.Description
stored.Version++
stored.Grants = slices.Clone(saved.Grants)
m.roles[saved.ID] = stored
return stored, nil
}
var _ role.Repository = &mockRoleRepo{}
@@ -0,0 +1,329 @@
package test
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
type accessBody struct {
Role struct {
ID uint32 `json:"id"`
Name string `json:"name"`
BuiltIn bool `json:"built_in"`
Locked bool `json:"locked"`
} `json:"role"`
Permissions []role.Grant `json:"permissions"`
}
type meBody struct {
ID uint32 `json:"id"`
Username string `json:"username"`
IsVerified bool `json:"is_verified"`
Role string `json:"role"`
Author any `json:"author"`
Access accessBody `json:"access"`
}
func (e *apiKeyEnv) addUser(id uint32, legacy user.Role, roleID uint32, verified bool) {
e.users.users[id] = user.User{ID: id, Username: "user" + string(rune('a'+id)), Email: "user" + string(rune('a'+id)) + "@example.com", IsVerified: verified, Role: legacy, RoleID: roleID}
}
func (e *apiKeyEnv) me(t *testing.T, id uint32) (*httptest.ResponseRecorder, meBody) {
t.Helper()
rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", id)
var body meBody
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
}
return rec, body
}
func permissionKeys(grants []role.Grant) []string {
keys := []string{}
for _, grant := range grants {
keys = append(keys, grant.Key)
}
return keys
}
func TestRolesPermissionsFoundationHTTP(t *testing.T) {
t.Run("The owner's /me carries the Owner role and all 34 permissions", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(7, user.RoleOwner, ownerRoleID, true)
rec, body := e.me(t, 7)
if rec.Code != http.StatusOK || rec.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("status %d cache %q", rec.Code, rec.Header().Get("Cache-Control"))
}
if body.ID != 7 || body.Role != "owner" || !body.IsVerified {
t.Errorf("existing fields changed: %+v", body)
}
if body.Access.Role.ID != ownerRoleID || body.Access.Role.Name != "Owner" || !body.Access.Role.BuiltIn || !body.Access.Role.Locked {
t.Errorf("role %+v", body.Access.Role)
}
want := []string{}
for _, permission := range role.Catalogue() {
want = append(want, permission.Key)
}
if !slices.Equal(permissionKeys(body.Access.Permissions), want) {
t.Errorf("permissions %v", permissionKeys(body.Access.Permissions))
}
for _, grant := range body.Access.Permissions {
if grant.Scope != role.ScopeAll {
t.Errorf("%s scope %s", grant.Key, grant.Scope)
}
}
})
t.Run("An admin's /me omits the owner-only keys", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(8, user.RoleAdmin, adminRoleID, true)
_, body := e.me(t, 8)
if body.Access.Role.Name != "Admin" || !body.Access.Role.BuiltIn || body.Access.Role.Locked {
t.Errorf("role %+v", body.Access.Role)
}
if len(body.Access.Permissions) != 29 {
t.Fatalf("expected 29 permissions, got %d", len(body.Access.Permissions))
}
scopes := grantMap(body.Access.Permissions)
for _, permission := range role.Catalogue() {
if _, present := scopes[permission.Key]; present == permission.OwnerOnly {
t.Errorf("%s presence %v", permission.Key, present)
}
}
if scopes["roles.view"] != role.ScopeAll || scopes["roles.manage"] != role.ScopeAll {
t.Errorf("roles keys missing")
}
})
t.Run("A writer's /me lists only granted keys with their scope", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
_, body := e.me(t, 9)
want := []role.Grant{
{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.create", Scope: role.ScopeAll},
{Key: "content.update", Scope: role.ScopeAll}, {Key: "schema.view", Scope: role.ScopeAll},
{Key: "media.view", Scope: role.ScopeAll}, {Key: "media.upload", Scope: role.ScopeAll},
{Key: "authors.view", Scope: role.ScopeAll}, {Key: "authors.edit", Scope: role.ScopeOwn},
{Key: "activity.view", Scope: role.ScopeOwn},
}
if !slices.Equal(body.Access.Permissions, want) {
t.Errorf("permissions %v", body.Access.Permissions)
}
})
t.Run("A user on a custom role sees that role", func(t *testing.T) {
e := newAPIKeyEnv(t)
custom, _ := e.roles.Create(role.Role{Name: "Contributor", Version: 1, Grants: []role.Grant{
{Key: "content.update", Scope: role.ScopeOwn}, {Key: "content.read", Scope: role.ScopeAll},
}})
e.addUser(10, user.RoleAdmin, custom.ID, true)
_, body := e.me(t, 10)
want := []role.Grant{{Key: "content.read", Scope: role.ScopeAll}, {Key: "content.update", Scope: role.ScopeOwn}}
if body.Access.Role.Name != "Contributor" || body.Access.Role.BuiltIn || !slices.Equal(body.Access.Permissions, want) {
t.Errorf("body %+v", body.Access)
}
})
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
e := newAPIKeyEnv(t)
e.addUser(11, legacy, 0, true)
_, body := e.me(t, 11)
if body.Access.Role.Name != name {
t.Errorf("%s: got %q", legacy, body.Access.Role.Name)
}
}
})
t.Run("The permissions follow the role live", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
writer := e.roles.byName("Writer")
writer.Grants = append(writer.Grants, role.Grant{Key: "media.delete", Scope: role.ScopeOwn})
_, _ = e.roles.Save(writer)
_, body := e.me(t, 9)
if grantMap(body.Access.Permissions)["media.delete"] != role.ScopeOwn {
t.Errorf("permissions %v", body.Access.Permissions)
}
})
t.Run("An unverified user still gets /me, with access", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(12, user.RoleAdmin, writerRoleID, false)
rec, body := e.me(t, 12)
if rec.Code != http.StatusOK || body.IsVerified || body.Access.Role.Name != "Writer" {
t.Errorf("status %d body %+v", rec.Code, body)
}
})
t.Run("A user whose role cannot be resolved gets a server error, not an empty access block", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(13, "", 99, true)
rec, _ := e.me(t, 13)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
}
})
t.Run("The existing /me failures are unchanged", func(t *testing.T) {
e := newAPIKeyEnv(t)
if rec := e.anonymous(t, http.MethodGet, "/v1/auth/me", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("no cookie: %d", rec.Code)
}
e.addUser(7, user.RoleOwner, ownerRoleID, true)
stale := e.users.users[7]
stale.TokenVersion = 1
e.users.users[7] = stale
rec := e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("stale token: %d %q", rec.Code, rec.Body.String())
}
delete(e.users.users, 7)
rec = e.withCookie(t, http.MethodGet, "/v1/auth/me", "", 7)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("deleted account: %d %q", rec.Code, rec.Body.String())
}
})
}
func TestPermissionCatalogueHTTP(t *testing.T) {
type item struct {
Key string `json:"key"`
ScopeKind string `json:"scope_kind"`
OwnerOnly bool `json:"owner_only"`
PerType bool `json:"per_type"`
}
type catalogueBody struct {
Groups []struct {
Key string `json:"key"`
Items []item `json:"items"`
} `json:"groups"`
}
t.Run("A signed-in verified user reads the catalogue", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var body catalogueBody
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
groups := []string{}
total := 0
for _, group := range body.Groups {
groups = append(groups, group.Key)
for _, found := range group.Items {
total++
permission, err := role.Lookup(found.Key)
if err != nil {
t.Fatalf("unknown key %s", found.Key)
}
wantKind := "scoped"
if permission.Kind == role.KindToggle {
wantKind = "toggle"
}
if found.ScopeKind != wantKind || found.OwnerOnly != permission.OwnerOnly {
t.Errorf("item %+v", found)
}
if found.PerType != (group.Key == "content") {
t.Errorf("per_type of %s: %v", found.Key, found.PerType)
}
}
}
if !slices.Equal(groups, []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"}) || total != 34 {
t.Errorf("groups %v total %d", groups, total)
}
if body.Groups[0].Items[0] != (item{Key: "content.read", ScopeKind: "scoped", PerType: true}) {
t.Errorf("first item %+v", body.Groups[0].Items[0])
}
})
t.Run("The catalogue is the same for every role", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(9, user.RoleAdmin, writerRoleID, true)
e.addUser(7, user.RoleOwner, ownerRoleID, true)
writer := e.withCookie(t, http.MethodGet, "/v1/permission", "", 9)
owner := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7)
if writer.Body.String() != owner.Body.String() || writer.Body.Len() == 0 {
t.Errorf("bodies differ")
}
})
t.Run("An API key may read the catalogue", func(t *testing.T) {
e := newAPIKeyEnv(t)
raw, _ := e.issueKey(apiKeyDevId, "reader")
if rec := e.withKey(t, http.MethodGet, "/v1/permission", "", raw); rec.Code != http.StatusOK {
t.Errorf("status %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("No cookie is refused", func(t *testing.T) {
e := newAPIKeyEnv(t)
if rec := e.anonymous(t, http.MethodGet, "/v1/permission", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("status %d", rec.Code)
}
})
t.Run("A stale token is refused", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(7, user.RoleOwner, ownerRoleID, true)
stored := e.users.users[7]
stored.TokenVersion = 1
e.users.users[7] = stored
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 7)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
}
})
t.Run("An unverified user is refused", func(t *testing.T) {
e := newAPIKeyEnv(t)
e.addUser(12, user.RoleAdmin, writerRoleID, false)
rec := e.withCookie(t, http.MethodGet, "/v1/permission", "", 12)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("status %d body %q", rec.Code, rec.Body.String())
}
})
}
func TestExistingRoleChecksAreUntouched(t *testing.T) {
t.Run("Existing role checks are untouched in this slice", func(t *testing.T) {
cases := []struct {
name string
user uint32
method string
path string
body string
denied bool
}{
{"owner invites", apiKeyOwnerId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, false},
{"admin invites", apiKeyDevId, http.MethodPost, "/v1/user/invite", `{"email":"new@example.com"}`, true},
{"admin creates a content type", apiKeyDevId, http.MethodPost, "/v1/content-type", `{}`, false},
{"admin patches settings", apiKeyDevId, http.MethodPatch, "/v1/settings", `{}`, true},
{"admin checks for updates", apiKeyDevId, http.MethodPost, "/v1/system/update/check", "", true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newAPIKeyEnv(t)
rec := e.withCookie(t, c.method, c.path, c.body, c.user)
if c.denied && (rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n") {
t.Errorf("expected owner-required 403, got %d %q", rec.Code, rec.Body.String())
}
if !c.denied && rec.Code == http.StatusForbidden {
t.Errorf("unexpected 403 %q", rec.Body.String())
}
})
}
})
}
+429
View File
@@ -0,0 +1,429 @@
package test
import (
"RenewCMS/internal/domain/role"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/persistence/database"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"slices"
"testing"
"gorm.io/gorm"
)
func roleWithGrant(key string, scope role.Scope) role.Role {
return role.Role{Name: "Probe", Grants: []role.Grant{{Key: key, Scope: scope}}}
}
func TestRoleAllows(t *testing.T) {
cases := []struct {
key string
scope role.Scope
owned bool
allowed bool
}{
{"content.update", role.ScopeAll, false, true},
{"content.update", role.ScopeAll, true, true},
{"content.update", role.ScopeOwn, true, true},
{"content.update", role.ScopeOwn, false, false},
{"content.update", role.ScopeNone, true, false},
{"content.create", role.ScopeAll, false, true},
{"schema.view", role.ScopeNone, false, false},
}
for _, c := range cases {
t.Run(string(c.scope)+" "+c.key, func(t *testing.T) {
if got := roleWithGrant(c.key, c.scope).Allows(c.key, c.owned); got != c.allowed {
t.Errorf("owned=%v: expected %v, got %v", c.owned, c.allowed, got)
}
})
}
t.Run("A key absent from the role is not allowed", func(t *testing.T) {
if (role.Role{}).Allows("content.read", true) {
t.Errorf("expected false")
}
})
t.Run("An unknown key is rejected, never silently allowed", func(t *testing.T) {
everything := role.BuiltInRoles()[0]
everything.Grants = append(everything.Grants, role.Grant{Key: "content.explode", Scope: role.ScopeAll})
if everything.Allows("content.explode", true) {
t.Errorf("expected false")
}
if _, err := role.Lookup("content.explode"); !errors.Is(err, role.ErrUnknownPermission) {
t.Errorf("expected ErrUnknownPermission, got %v", err)
}
})
}
func TestRoleCatalogue(t *testing.T) {
keys := []string{
"content.read", "content.create", "content.update", "content.delete", "content.publish",
"schema.view", "schema.edit", "schema.delete",
"media.view", "media.upload", "media.delete",
"authors.view", "authors.create", "authors.edit", "authors.delete",
"activity.view", "activity.export",
"users.view", "users.invite", "users.change_role", "users.remove",
"settings.view", "settings.edit_general", "settings.manage_sites", "settings.delete_site",
"apikeys.view", "apikeys.create", "apikeys.revoke",
"roles.view", "roles.manage", "roles.edit_owner_only",
"system.apply_updates", "system.edit_retention", "system.transfer_ownership",
}
t.Run("The catalogue is stable and complete", func(t *testing.T) {
catalogue := role.Catalogue()
got := []string{}
ownerOnly := []string{}
scoped := []string{}
for _, permission := range catalogue {
got = append(got, permission.Key)
if permission.OwnerOnly {
ownerOnly = append(ownerOnly, permission.Key)
}
if permission.Kind == role.KindScoped {
scoped = append(scoped, permission.Key)
}
if permission.Group == "content" && permission.Kind == role.KindToggle && permission.Key != "content.create" {
t.Errorf("unexpected toggle %s", permission.Key)
}
}
if !slices.Equal(got, keys) {
t.Errorf("catalogue order differs: %v", got)
}
wantOwnerOnly := []string{"settings.delete_site", "roles.edit_owner_only", "system.apply_updates", "system.edit_retention", "system.transfer_ownership"}
if !slices.Equal(ownerOnly, wantOwnerOnly) {
t.Errorf("owner-only keys: %v", ownerOnly)
}
wantScoped := []string{"content.read", "content.update", "content.delete", "content.publish", "media.delete", "authors.edit", "activity.view", "apikeys.view", "apikeys.revoke"}
if !slices.Equal(scoped, wantScoped) {
t.Errorf("scoped keys: %v", scoped)
}
wantGroups := []string{"content", "schema", "media", "authors", "activity", "users", "settings", "roles", "system"}
if !slices.Equal(role.Groups(), wantGroups) {
t.Errorf("groups: %v", role.Groups())
}
})
}
func grantMap(grants []role.Grant) map[string]role.Scope {
scopes := map[string]role.Scope{}
for _, grant := range grants {
scopes[grant.Key] = grant.Scope
}
return scopes
}
func openMigratedRolesDB(t *testing.T) *gorm.DB {
t.Helper()
db := openUnmigratedSQLite(t)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func storedRole(t *testing.T, db *gorm.DB, name string) role.Role {
t.Helper()
found, err := persistence.NewRoleRepository(db).GetByName(name)
if err != nil {
t.Fatalf("role %s: %v", name, err)
}
return found
}
func countRows(t *testing.T, db *gorm.DB, model any) int64 {
t.Helper()
var total int64
if err := db.Model(model).Count(&total).Error; err != nil {
t.Fatalf("count: %v", err)
}
return total
}
func TestSeedBuiltInRoles(t *testing.T) {
t.Run("Seeding an empty database creates the three built-in roles", func(t *testing.T) {
db := openMigratedRolesDB(t)
owner, admin, writer := storedRole(t, db, "Owner"), storedRole(t, db, "Admin"), storedRole(t, db, "Writer")
for _, found := range []role.Role{owner, admin, writer} {
if !found.BuiltIn || found.Version != 1 {
t.Errorf("%s: built_in %v version %d", found.Name, found.BuiltIn, found.Version)
}
}
if !owner.Locked || admin.Locked || writer.Locked {
t.Errorf("only Owner is locked")
}
if len(owner.Grants) != 34 || len(admin.Grants) != 29 || len(writer.Grants) != 9 {
t.Fatalf("grant counts %d %d %d", len(owner.Grants), len(admin.Grants), len(writer.Grants))
}
for _, grant := range append(slices.Clone(owner.Grants), admin.Grants...) {
if grant.Scope != role.ScopeAll {
t.Errorf("%s not at all", grant.Key)
}
}
for _, grant := range admin.Grants {
if permission, _ := role.Lookup(grant.Key); permission.OwnerOnly {
t.Errorf("admin holds owner-only %s", grant.Key)
}
}
scopes := grantMap(writer.Grants)
if scopes["authors.edit"] != role.ScopeOwn || scopes["activity.view"] != role.ScopeOwn || scopes["content.create"] != role.ScopeAll {
t.Errorf("writer grants %v", scopes)
}
})
t.Run("Seeding twice changes nothing", func(t *testing.T) {
db := openMigratedRolesDB(t)
ownerID := storedRole(t, db, "Owner").ID
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatal(err)
}
if countRows(t, db, &models.Role{}) != 3 || countRows(t, db, &models.RolePermission{}) != 72 || storedRole(t, db, "Owner").ID != ownerID {
t.Errorf("seeding was not idempotent")
}
})
t.Run("Seeding never overwrites an edited built-in role", func(t *testing.T) {
db := openMigratedRolesDB(t)
writer := storedRole(t, db, "Writer")
if err := db.Model(&models.Role{}).Where("id = ?", writer.ID).Update("version", 4).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&models.RolePermission{RoleID: writer.ID, Key: "media.delete", Scope: "own"}).Error; err != nil {
t.Fatal(err)
}
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatal(err)
}
after := storedRole(t, db, "Writer")
if after.Version != 4 || grantMap(after.Grants)["media.delete"] != role.ScopeOwn {
t.Errorf("edited writer overwritten: %+v", after)
}
})
t.Run("Seeding never touches a custom role that reuses a built-in name", func(t *testing.T) {
db := openUnmigratedSQLite(t)
if err := db.AutoMigrate(&models.Role{}, &models.RolePermission{}); err != nil {
t.Fatal(err)
}
custom := models.Role{Name: "Writer", Version: 7}
if err := db.Create(&custom).Error; err != nil {
t.Fatal(err)
}
if err := database.SeedBuiltInRoles(db); err != nil {
t.Fatalf("startup aborted: %v", err)
}
var kept models.Role
if err := db.First(&kept, custom.ID).Error; err != nil || kept.BuiltIn || kept.Version != 7 {
t.Errorf("custom row changed: %+v %v", kept, err)
}
if found := storedRole(t, db, "Writer"); !found.BuiltIn || found.ID == custom.ID {
t.Errorf("built-in writer missing: %+v", found)
}
})
}
func createUserRow(t *testing.T, db *gorm.DB, id uint32, legacy string, roleID *uint32) {
t.Helper()
row := models.User{ID: id, Username: "u" + legacy + string(rune('a'+id)), Email: legacy + string(rune('a'+id)) + "@x.example", Password: "x", Role: legacy, RoleID: roleID}
if err := db.Create(&row).Error; err != nil {
t.Fatalf("user %d: %v", id, err)
}
}
func userRow(t *testing.T, db *gorm.DB, id uint32) models.User {
t.Helper()
var row models.User
if err := db.First(&row, id).Error; err != nil {
t.Fatal(err)
}
return row
}
func roleIDOf(row models.User) uint32 {
if row.RoleID == nil {
return 0
}
return *row.RoleID
}
func TestBackfillUserRoleIDs(t *testing.T) {
t.Run("Existing owner and admin users get the matching built-in role", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createUserRow(t, db, 5, "admin", nil)
createUserRow(t, db, 9, "admin", nil)
if err := database.BackfillUserRoleIDs(db); err != nil {
t.Fatal(err)
}
ownerID, adminID := storedRole(t, db, "Owner").ID, storedRole(t, db, "Admin").ID
if roleIDOf(userRow(t, db, 3)) != ownerID || roleIDOf(userRow(t, db, 5)) != adminID || roleIDOf(userRow(t, db, 9)) != adminID {
t.Errorf("role ids not backfilled")
}
if userRow(t, db, 3).Role != "owner" || userRow(t, db, 5).Role != "admin" {
t.Errorf("legacy role changed")
}
})
t.Run("The backfill runs after BackfillUserRoles on a database that predates both", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 2, "", nil)
createUserRow(t, db, 4, "", nil)
if err := database.Migrate(db, migrationPublicURL); err != nil {
t.Fatal(err)
}
two, four := userRow(t, db, 2), userRow(t, db, 4)
if two.Role != "owner" || roleIDOf(two) != storedRole(t, db, "Owner").ID {
t.Errorf("user 2: %+v", two)
}
if four.Role != "admin" || roleIDOf(four) != storedRole(t, db, "Admin").ID {
t.Errorf("user 4: %+v", four)
}
})
t.Run("The role_id backfill is idempotent and respects an existing role_id", func(t *testing.T) {
db := openMigratedRolesDB(t)
custom, err := persistence.NewRoleRepository(db).Create(role.Role{Name: "Contributor", Version: 1})
if err != nil {
t.Fatal(err)
}
createUserRow(t, db, 5, "admin", &custom.ID)
for range 2 {
if err := database.BackfillUserRoleIDs(db); err != nil {
t.Fatal(err)
}
}
if roleIDOf(userRow(t, db, 5)) != custom.ID {
t.Errorf("custom role replaced")
}
})
t.Run("A database with no users is left alone", func(t *testing.T) {
if err := database.BackfillUserRoleIDs(openMigratedRolesDB(t)); err != nil {
t.Errorf("unexpected error %v", err)
}
})
}
func createdByOf(t *testing.T, db *gorm.DB, table string, id uint32) uint32 {
t.Helper()
var value uint32
if err := db.Table(table).Unscoped().Select("created_by").Where("id = ?", id).Scan(&value).Error; err != nil {
t.Fatal(err)
}
return value
}
func createArticleRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
t.Helper()
if err := db.Create(&models.Article{ID: id, Title: "Article", Slug: "a-" + string(rune('a'+id)), CreatedBy: createdBy, SiteID: 1}).Error; err != nil {
t.Fatal(err)
}
}
func createEntryRow(t *testing.T, db *gorm.DB, id uint32, createdBy uint32) {
t.Helper()
if err := db.Create(&models.Entry{ID: id, ContentTypeID: 1, Data: "{}", CreatedBy: createdBy}).Error; err != nil {
t.Fatal(err)
}
}
func TestBackfillCreatedBy(t *testing.T) {
t.Run("Existing articles are attributed to the first owner", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createUserRow(t, db, 4, "owner", nil)
createArticleRow(t, db, 10, 0)
createArticleRow(t, db, 11, 0)
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
if createdByOf(t, db, "articles", 10) != 3 || createdByOf(t, db, "articles", 11) != 3 {
t.Errorf("articles not attributed")
}
})
t.Run("Entries without an author are attributed to the first owner", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createEntryRow(t, db, 20, 0)
createEntryRow(t, db, 21, 7)
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
if createdByOf(t, db, "entries", 20) != 3 || createdByOf(t, db, "entries", 21) != 7 {
t.Errorf("entries wrong")
}
})
t.Run("The created_by backfill never rewrites an existing value, and is idempotent", func(t *testing.T) {
db := openMigratedRolesDB(t)
createUserRow(t, db, 3, "owner", nil)
createArticleRow(t, db, 10, 7)
for range 2 {
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatal(err)
}
}
if createdByOf(t, db, "articles", 10) != 7 {
t.Errorf("existing value rewritten")
}
})
t.Run("With no owner yet the backfill does nothing", func(t *testing.T) {
db := openMigratedRolesDB(t)
createArticleRow(t, db, 10, 0)
if err := database.BackfillCreatedBy(db); err != nil {
t.Fatalf("unexpected error %v", err)
}
if createdByOf(t, db, "articles", 10) != 0 {
t.Errorf("created_by changed")
}
})
}
func TestNewArticlesRecordTheirCreator(t *testing.T) {
t.Run("New articles record their creator", func(t *testing.T) {
repo := newMockArticleRepo()
uc := useCases.NewCreateArticleUseCase(repo, &fakeBodyRenderer{}, discardRecorder, plainSites(), plainAuthors())
created, err := uc.CreateArticle(testCtx, useCases.CreateArticleCommand{Title: "Valid Title", Body: "x", CreatedBy: 7})
if err != nil {
t.Fatal(err)
}
if repo.articles[created.ID].CreatedBy != 7 {
t.Errorf("stored created_by %d", repo.articles[created.ID].CreatedBy)
}
})
}
func TestGetMyAccessUseCase(t *testing.T) {
t.Run("A user with no role_id falls back to the built-in role named by the legacy role", func(t *testing.T) {
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
for legacy, name := range map[user.Role]string{user.RoleOwner: "Owner", user.RoleAdmin: "Admin"} {
access, err := uc.Get(user.User{ID: 11, Role: legacy})
if err != nil || access.Role.Name != name {
t.Errorf("%s: %v %v", legacy, access.Role.Name, err)
}
}
})
t.Run("A user whose role cannot be resolved gets an error", func(t *testing.T) {
uc := useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo())
if _, err := uc.Get(user.User{ID: 13, RoleID: 99}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
if _, err := uc.Get(user.User{ID: 13}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
})
t.Run("The fallback never picks a custom role that reuses a built-in name", func(t *testing.T) {
repo := newMockRoleRepo()
_, _ = repo.Create(role.Role{Name: "Owner"})
if _, err := useCases.NewGetMyAccessUseCase(repo).Get(user.User{Role: user.RoleOwner}); !errors.Is(err, role.ErrRoleNotFound) {
t.Errorf("expected ErrRoleNotFound, got %v", err)
}
})
}
@@ -137,6 +137,7 @@ func newRuntimeSettingsContainer(t *testing.T) *api.UseCases {
DeleteArticleUseCase: useCases.NewDeleteArticleUseCase(articleRepo, discardRecorder),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, mailRepo, "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
CancelRegistrationUseCase: useCases.NewCancelRegistrationUseCase(userRepo),
+1
View File
@@ -176,6 +176,7 @@ func newSessionRevocationEnv(t *testing.T) *sessionRevocationEnv {
Token: authtoken.New([]byte(testJWTSecret), authtoken.WithRevocationStore(useCases.NewSessionRevocationUseCase(sessions, time.Now))),
GetUserUseCase: useCases.NewGetUserUseCase(userRepo),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
CreateUserUseCase: useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
AuthenticateUserUseCase: useCases.NewAuthenticateUserUseCase(userRepo, hasher, discardRecorder),
}
+6 -3
View File
@@ -176,7 +176,7 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
if _, indexed := after.schema["idx_articles_site_slug"]; !indexed {
t.Errorf("the unique (site_id, slug) index is missing: %v", after.schema)
}
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id"} {
for _, name := range []string{"articles", "idx_articles_site_id", "idx_articles_site_slug", "idx_articles_cover_image_id", "idx_articles_social_image_id", "users", "idx_users_role_id"} {
delete(before.schema, name)
delete(after.schema, name)
}
@@ -190,13 +190,16 @@ func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
for _, column := range addedArticleColumns {
delete(after.rows["articles"][0], column)
}
for _, row := range after.rows["users"] {
delete(row, "role_id")
}
if !reflect.DeepEqual(before.rows, after.rows) {
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
}
})
}
var addedArticleColumns = []string{"site_id", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
var addedArticleColumns = []string{"site_id", "created_by", "slug", "excerpt", "seo_title", "seo_description", "cover_image_id", "cover_image_alt", "social_title", "social_description", "social_image_id", "canonical_url", "noindex", "seo_dismissed"}
func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
t.Helper()
@@ -216,7 +219,7 @@ func assertSiteColumnAdded(t *testing.T, db *gorm.DB) {
}
func isNewTableObject(name string) bool {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_"} {
for _, prefix := range []string{"audit_", "idx_audit_", "image_variant", "idx_image_variant", "idx_images_path", "idx_sites_key", "sites", "authors", "idx_authors_", "article_authors", "idx_article_authors_", "content_types", "idx_content_types_", "entries", "idx_entries_", "revoked_sessions", "idx_revoked_sessions_", "role", "idx_role"} {
if strings.HasPrefix(name, prefix) {
return true
}
+1
View File
@@ -39,6 +39,7 @@ func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: rig.status,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now, discardRecorder),
StartUpdateDownloadUseCase: rig.start,
+1
View File
@@ -64,6 +64,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now, discardRecorder),
}
+1
View File
@@ -59,6 +59,7 @@ func newRestartHTTP(t *testing.T) *restartHTTP {
Token: h.token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
GetMyAuthorUseCase: useCases.NewGetMyAuthorUseCase(plainAuthors()),
GetMyAccessUseCase: useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, false, h.clock.Now),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
+5 -4
View File
@@ -27,7 +27,7 @@ func newDisplayNameRouter(userRepo *mockUserRepo, token *authtoken.Auth) *chi.Mu
getUser := useCases.NewGetUserUseCase(userRepo)
update := useCases.NewUpdateUserProfileUseCase(userRepo, discardRecorder)
handler := userhandler.NewHandler(update)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
loginHandler := auth.NewLoginHandler(
token,
useCases.NewCreateUserUseCase(userRepo, hasher, newMockMailRepo(), "https://cms.example.com", discardRecorder),
@@ -91,6 +91,7 @@ func seededDisplayNameRepo(displayName string) *mockUserRepo {
Email: "marta@renew.example",
Password: "hashed:correct-horse",
IsVerified: true,
Role: user.RoleOwner,
DisplayName: displayName,
}
return repo
@@ -123,7 +124,7 @@ func assertSevenKeys(t *testing.T, body []byte) map[string]json.RawMessage {
func assertMeKeys(t *testing.T, body []byte) map[string]json.RawMessage {
t.Helper()
decoded := decodeKeys(t, body)
expected := append(append([]string{}, displayNameResponseKeys...), "author")
expected := append(append([]string{}, displayNameResponseKeys...), "author", "access")
if len(decoded) != len(expected) {
t.Errorf("expected exactly %d keys, got %d: %s", len(expected), len(decoded), string(body))
}
@@ -392,7 +393,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
t.Run("GET /v1/auth/me returns an empty display_name, not a missing key, when none is set", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin}
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodGet, "/v1/auth/me", "", displayNameCookie(t, token, 8, 0))
if rec.Code != http.StatusOK {
@@ -420,7 +421,7 @@ func TestUserDisplayNameHttp(t *testing.T) {
t.Run("An unverified user is refused", func(t *testing.T) {
repo := newMockUserRepo()
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false}
repo.users[8] = user.User{ID: 8, Username: "bob", IsVerified: false, Role: user.RoleAdmin}
router := newDisplayNameRouter(repo, token)
rec := sendDisplayNameRequest(router, http.MethodPatch, "/v1/user/me", `{"display_name": "Bob"}`, displayNameCookie(t, token, 8, 0))
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
+1 -1
View File
@@ -60,7 +60,7 @@ func newUserRolesFixture(t *testing.T, maxAttempts int) *userRolesFixture {
f.token,
true,
)
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()))
meHandler := auth.NewMeHandler(getUser, useCases.NewGetMyAuthorUseCase(plainAuthors()), useCases.NewGetMyAccessUseCase(newSeededMockRoleRepo()))
inviteHandler := userhandler.NewInviteHandler(
useCases.NewCreateInviteUseCase(f.users, f.invites, hasher, f.mail, invitePublicURL, discardRecorder),
nil,