refac: debt on role backend

This commit is contained in:
Florian Sylvain committed 2026-10-26 23:34:35 +01:00
1 parent de142f6c5f
commit a043f7f41a
130 files changed
+3591 -512

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
Feature: Article body content negotiation on read
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As a headless frontend developer
I want article routes to return the body as markdown (default), html or a typed json block array
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: article_body_format
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box). New files: `test/article_body_format_test.go`, `test/markdown_converter_test.go`, `test/json_blocks_converter_test.go`. No live server, no real SQLite file, no real SMTP.
## Setup
+1
View File
@@ -1,4 +1,5 @@
Feature: Article bodies have no length limit and the body is optional
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As a verified RenewCMS owner
I want to create an article with only a title and write articles of any length
+2
View File
@@ -1,5 +1,7 @@
# QA: unlimited, optional article body
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
## Part A. HTTP (Go, test/article_body_unlimited_http_test.go)
Setup: in-memory fake article repository plus the real markdown converter as BodyRenderer (so the "json" format of an empty body is genuinely exercised). Build CreateArticle, GetArticle, UpdateArticle use cases and the article handler via NewHandler(...). Mount on a fresh chi.NewRouter() that mirrors api/router.go: POST /v1/article/ and PATCH /v1/article/{id} inside jwtauth.Verifier + Authenticator with a test jwtauth.New("HS256", ...) token. Call validator.InitValidator(). Use httptest. No live server, no real SQLite, no real SMTP.
+1
View File
@@ -1,4 +1,5 @@
Feature: An authenticated user can publish and unpublish an article
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As an authenticated RenewCMS user managing the CMS
I want to flip an article's is_online flag from an HTTP endpoint
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: article_publishing
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, extending `article_test.go`).
## Setup shared by every case
+1
View File
@@ -1,4 +1,5 @@
Feature: The article title rule is the same in the request and the domain
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS
I want PostArticle's title validation and article.CreateArticle's title validation to
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: article_title_rules
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, alongside `article_test.go` / `use_case_baseline_test.go` /
`not_found_errors_test.go` / the hasher-domain-interface tests).
+1
View File
@@ -1,4 +1,5 @@
Feature: Articles list screen
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As a verified RenewCMS owner
I want /article to list every article with its status, let me search and filter it, and let me
+2
View File
@@ -1,5 +1,7 @@
# QA: Articles list screen
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
## Implementation note for coder (informational, not a QA assertion)
`internal/infrastructure/persistence/repositories/articleRepository.go`'s `FindByFilters` gains
+1
View File
@@ -1,4 +1,5 @@
Feature: Paginated article listing
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As an API consumer or the RenewCMS back-office
I want GET /v1/article/ to always answer one page of articles with the total and the tab counts
+2
View File
@@ -1,5 +1,7 @@
# QA: Paginated article listing (API)
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
## HTTP-level (Go, `test/`, `task test`)
Build `article.NewHandler(...)` over the real `ListArticlesUseCase` and a fake `article.Repository` (extend `mockArticleRepo` in `test/article_test.go`, keeping type names unique across files). The fake implements the interface contract: paging (Page/Size 0 = unpaged), q/status filtering, ordering updated_at DESC then id DESC, total, and the visibility counts. Mount on a fresh `chi.NewRouter()` mirroring `NewArticleRouter` in `api/router.go` (GET / under `jwtauth.Verifier` only, test `jwtauth.New("HS256", ...)`). Call `validator.InitValidator()`. Use a user fake for the verified, unverified and missing cases, as in `test/public_article_listing_test.go`. No live server, no SQLite file.
+1
View File
@@ -1,4 +1,5 @@
Feature: Owners and admins define custom content types, and users manage entries of those types through a generic /v1 API
Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
As a site owner or admin
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: custom content types (backend core)
> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
@@ -1,4 +1,5 @@
Feature: Content types carry an optional icon, built-in types are readable, and verified users can resolve user display names
Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
As an owner, admin or verified user working in the SPA
@@ -1,5 +1,7 @@
# QA procedure: custom content types support (icon, built-in read model, user lookup)
> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP.
@@ -1,4 +1,5 @@
Feature: Deleting an article never deletes images
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As an authenticated RenewCMS user
I want deleting an article to leave the media library alone
@@ -1,5 +1,7 @@
# QA procedure: delete_article_keeps_images
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box). Wire `useCases.NewDeleteArticleUseCase(articleRepo)` (single argument) with the other article use cases and mount as in the retired `delete_article_cascades_images.qa.md`: `Verifier` plus `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, jwt cookie for user id 1. Seed images 10 and 11 in `mockImageRepo` and the article body with their urls. Call `validator.InitValidator()` first.
## HTTP checks
+1
View File
@@ -1,4 +1,5 @@
Feature: An authenticated user can edit an article's body
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As an authenticated RenewCMS user managing the CMS
I want to change an existing article's body from an HTTP endpoint
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: edit_article_body
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, extending `article_test.go`).
## Setup shared by every case
+1
View File
@@ -1,4 +1,5 @@
Feature: Media field type for custom content types
Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
As a site owner or admin
I want a content type field that holds one image from the media library
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: media field type (slice A, API only)
> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP, no SPA.
## Setup shared by every case
+1
View File
@@ -1,4 +1,5 @@
Feature: Domain not-found errors instead of raw GORM errors
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS
I want a missing article or user reported as a domain not-found error
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: not_found_errors
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, alongside the existing `article_test.go` / `user_test.go` / `use_case_baseline_test.go`).
## Setup shared by every case
+1
View File
@@ -1,4 +1,5 @@
Feature: Public and authenticated article listing
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As a visitor of the public site and as an authenticated RenewCMS user
I want GET /v1/article/ to return every online article to anyone,
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: public_article_listing
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, extending `article_test.go`).
## Setup shared by every case
+1
View File
@@ -1,4 +1,5 @@
Feature: Single article read follows the same online/offline visibility rule as the list
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As a visitor of the public site and as an authenticated RenewCMS user
I want GET /v1/article/{id} to return an online article to anyone, and an offline article
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: public_article_read
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, extending `article_test.go`, `verified_account_required_http_test.go`
and `not_found_errors_test.go`).
+1
View File
@@ -1,4 +1,5 @@
Feature: Relation field type for custom content types
Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
As a site owner or admin
I want a content type field that links an entry to entries of another content type
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: relation field type (API only)
> Superseded in part by roles_permissions_content_scope: the entry routes are gated by the content.* permissions for the content type of the URL (a refusal is 403 "missing_permission" with the header X-Missing-Permission), own scope is enforced on created_by (a list under own shows the caller's entries only and another's entry is 404 "entry_not_found"), and creating a content type whose key is article, author, site, media or user is refused with 409 "content_type_key_taken".
Package: `test` (black-box). No live server, no real SQLite file, no real SMTP, no SPA.
## Setup shared by every case
@@ -1,4 +1,5 @@
Feature: The legacy POST /v1/article/{id}/image alias is removed
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS
I want the editor to upload through /v1/media only and the article alias deleted
@@ -1,5 +1,7 @@
# QA procedure: remove_legacy_article_image_alias
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, httptest, in-memory fakes, no live server).
## Setup
@@ -0,0 +1,464 @@
Feature: Roles and permissions content scope: own and all enforcement on articles and entries, Publish as its own permission, typed grants
As the owner of a RenewCMS site
I want articles and entries to be checked against the caller's content permissions, by owner and by content type
So that a Contributor edits only their own work, a Writer cannot publish, and a role can differ per content type
Background:
Given the spec of record is docs/design/screens/roles.md (catalogue, "Hidden or disabled", "HTTP status to copy", sections 6 and 7) and docs/design/routes.md "Roles and permissions" (created_by, Enforcement)
And features/roles_permissions_foundation.feature provides the catalogue and built-in roles, and features/roles_permissions_enforcement.feature provides the 403 contract:
status 403, body "missing_permission\n", header X-Missing-Permission, decided from the caller's live role on every request, API keys following their creator
And this slice supersedes two clauses of the enforcement feature: owner-only keys are effective only on a Locked role (see "Owner-only keys are effective only on a locked role"),
and articles and entries are no longer ungated
And "ok" means the request passes the gate and the scope check. A later 400, 404 or 409 from the handler is not a refusal
And "403" means status 403, body "missing_permission\n", the header X-Missing-Permission holding the BASE catalogue key (content.update, never content.update:events),
no side effect, no audit event, and the use case writes nothing
And a refusal by scope is a role.MissingPermissionError returned by the use case (errors.Is role.ErrMissingPermission, errors.As for the key), written by access.WriteIfMissingPermission
And the typed key form is content.<action>:<type_key>. The type key of articles is the built-in key "article". The type key of an entry is the content type key of the URL
And a typed grant replaces the wildcard for that type, whether it lowers or raises it. Only the wildcard applies to a type with no typed row
And typed keys exist only for the 5 content.* keys. A typed row of scope none is an explicit deny for that type
And each action reads its own key, with no implied keys: content.publish does not imply content.update, content.update does not imply content.read, content.read implies nothing
And own means the caller's user id equals CreatedBy. All means any. None means no. created_by 0 (unattributed) is owned by nobody, and caller id 0 owns nothing
And scope is decided AFTER loading the article or entry, by the use case, from the caller's resolved grants passed in by the handler. Use cases never read user.Role
And the handler path for a scoped write is: Authenticator, RequireCurrentToken, RequireVerifiedUser, then a gate that refuses when the typed scope is none (before any lookup), then id/body validation (400), then the use case:
load (404), scope check (403) immediately after the load and before any other lookup or validation, then the write
And an id that does not exist answers 404 even for a caller whose scope is own, because ownership is unknown
And entries have no status. content.publish applies to articles only (PATCH .../publish and .../unpublish). No schedule route exists. A future one must use content.publish
And the type key "article" is shared with entry routes. Creating a content type whose key equals a built-in key (article, author, site, media, user) answers 409 apperr.ContentTypeKeyTakenErrorMessage
And the users (all verified, valid jwt cookie, token_version 0) are the foundation's user 3 Owner, user 5 Admin, user 9 Writer, plus these on custom, unlocked roles:
| user | role | grants |
| 21 | Contributor | content.read=all, content.create=all, content.update=own |
| 22 | Reader own | content.read=own, content.create=all |
| 23 | Editor | content.read=all, content.update=all, content.publish=all, content.delete=own |
| 24 | Publisher own | content.read=all, content.publish=own |
| 25 | Typed A | content.read=all, content.create=all, content.update=all, content.update:events=own, content.update:article=none |
| 26 | Typed B | content.read:events=all, content.create:events=all, content.update:events=own |
| 27 | No content | schema.view=all |
And the articles are:
| id | state | created_by |
| 10 | draft | 21 |
| 11 | draft | 9 |
| 12 | online| 3 |
| 13 | draft | 3 |
| 14 | draft | 0 |
| 15 | online| 21 |
| 16 | draft | 22 |
| 17 | draft | 24 |
And the entries are, in content types "events" and "venues":
| id | type | created_by |
| 1 | events | 21 |
| 2 | events | 9 |
| 3 | events | 0 |
| 4 | events | 25 |
| 5 | venues | 3 |
| 6 | events | 22 |
| 7 | events | 26 |
| 8 | events | 23 |
Scenario Outline: GrantSet.ScopeFor resolves wildcard and typed keys
Given a role (not locked) with grants <grants>
When ScopeFor("<key>", "<type>") is evaluated
Then the scope is <scope>
Examples:
| grants | key | type | scope |
| content.update=all | content.update | | all |
| content.update=all | content.update | blog | all |
| content.update=all, content.update:blog=own | content.update | blog | own |
| content.update=all, content.update:blog=own | content.update | news | all |
| content.update=all, content.update:blog=none | content.update | blog | none |
| content.update=own, content.update:blog=all | content.update | blog | all |
| content.update:blog=all | content.update | blog | all |
| content.update:blog=all | content.update | news | none |
| content.update:blog=all | content.update | | none |
| content.create=all, content.create:blog=none | content.create | blog | none |
| content.create=all, content.create:blog=all | content.create | blog | all |
| content.read=all | content.read | Bad Key | none |
| content.read=all | content.read | (101 characters) | none |
| schema.view=all | schema.view | blog | none |
| schema.view=all | schema.view | | all |
| content.explode=all | content.explode| | none |
| (no grants) | content.read | | none |
Scenario Outline: One helper decides own and all
Given a role holding content.update at <scope>
When Permits("content.update", "blog", caller=<caller>, createdBy=<created_by>) is evaluated
Then the result is <allowed>
Examples:
| scope | caller | created_by | allowed |
| all | 9 | 21 | true |
| all | 9 | 0 | true |
| own | 21 | 21 | true |
| own | 9 | 21 | false |
| own | 21 | 0 | false |
| own | 0 | 0 | false |
| none | 21 | 21 | false |
Scenario: The own and all rule has exactly one implementation
Then internal/domain/role holds one exported helper deciding ownership (GrantSet.Permits, built on GrantSet.ScopeFor)
And no use case, handler or middleware compares a caller id with CreatedBy, or reads a GrantSet entry, for an authorization decision. They call ScopeFor, Permits, Holds or HoldsAll
And the 2a decisions for activity.view, apikeys.view, apikeys.revoke and authors.edit are evaluated through the same helper, with the same results as in the enforcement feature
And for authors.edit own means "the author linked to the caller" (existing rule), derived from ScopeFor, not from CreatedBy
Scenario: Role.Allows and AllowsFor are removed
Then Role no longer has Allows, AllowsFor or scopeOf
And a role is evaluated only through Role.GrantSet(), the single constructor of a GrantSet. NewGrantSet is unexported or removed
And Role.GrantSet() drops owner-only keys when the role is not Locked, drops unknown keys, drops typed keys on non-content keys or with an invalid type key,
and normalises duplicates (see next scenarios)
And a toggle key stored at scope own, or any key stored at a scope other than none, own or all, is treated as none
Scenario Outline: Duplicate rows resolve to the most restrictive scope
Given a stored role with the rows <rows> in this order
When the GrantSet is built
Then ScopeFor("<key>", "<type>") is <scope>
And the result is the same when the rows are in the opposite order
Examples:
| rows | key | type | scope |
| content.update=own, content.update=all | content.update | | own |
| content.update=all, content.update=own | content.update | | own |
| content.update:blog=all, content.update:blog=own | content.update | blog | own |
| content.update=all, content.update:blog=own | content.update | blog | own |
| content.read=all, content.read=none | content.read | | none |
Scenario Outline: ValidateGrants rejects duplicates and validates typed keys
Given a role to be written with the grants <grants>
When ValidateGrants(locked=false, grants) is evaluated
Then the result is <result> (errors.Is)
Examples:
| grants | result |
| content.update=all, content.update=own | role.ErrDuplicateGrant |
| content.update:blog=all, content.update:blog=own| role.ErrDuplicateGrant |
| content.update=all, content.update:blog=own | nil |
| content.update:blog=none | nil |
| content.update=none | role.ErrInvalidScope |
| content.create:blog=none | nil |
| content.create:blog=own | role.ErrInvalidScope |
| content.update:Bad Key=all | role.ErrUnknownPermission |
| schema.view:blog=all | role.ErrUnknownPermission |
| settings.delete_site=all | role.ErrOwnerOnlyGrant |
Scenario: Owner-only keys are effective only on a locked role
Given a custom role, not locked, with the hand-placed grant system.apply_updates=all
When user 20 sends POST /v1/system/update/check
Then the response is 403 missing_permission with X-Missing-Permission "system.apply_updates"
And GET /v1/auth/me for that user does not list system.apply_updates
And the built-in Owner role, which is Locked, still holds all 34 keys
And the same refusal applies to settings.delete_site, roles.edit_owner_only, system.edit_retention and system.transfer_ownership on an unlocked role
Scenario: The permission check loads the role once and no grants separately
Given a role repository that counts calls and a user repository that counts calls
When a gated request is sent with a session cookie
Then the user repository Get is called at most once for the whole request, and the role repository Get (or GetByName for the legacy fallback) exactly once
And ListGrants is called zero times by the permission check and by GetMyAccessUseCase
And with an API key the user repository Get is called at most twice (key authentication plus one request-scoped load)
And the status codes and the order of checks of the enforcement feature are unchanged
And a request-scoped current-user holder is filled by the first middleware that loads the user, so RequireCurrentToken, RequireVerifiedUser, the permission gate and AuditContext do not each reload it
Scenario: The router uses role.Key constants
Then internal/domain/role/roleKeys.go holds one exported constant per catalogue key (34), named from the key (content.read is KeyContentRead)
And api/router.go and api/middleware contain no string literal equal to a catalogue key
And a drift test fails when a catalogue key has no constant, or a constant has no catalogue key
Scenario Outline: Reading articles shows what the caller may see
Given the articles of the Background
When <caller> sends GET /v1/article/<id>
Then the result is <result>
Examples:
| caller | id | result |
| no cookie | 12 | 200 |
| no cookie | 10 | 404 Article not found |
| user 12 unverified, Admin role | 10 | 404 Article not found |
| user 9 Writer (read all) | 10 | 200 |
| user 9 Writer | 14 | 200 |
| user 22 (read own) | 16 | 200 |
| user 22 (read own) | 12 | 200 |
| user 22 (read own) | 10 | 404 Article not found |
| user 22 (read own) | 14 | 404 Article not found |
| user 27 (no content.read) | 12 | 200 |
| user 27 (no content.read) | 10 | 404 Article not found |
| user 26 (typed read on events only) | 10 | 404 Article not found |
| user 13, role_id 99, unresolvable | 12 | 200 |
| user 13, role_id 99, unresolvable | 10 | 404 Article not found |
| API key created by user 9 | 10 | 200 |
| API key created by user 22 | 10 | 404 Article not found |
Scenario: Public article routes never answer 403
Given user 27 holds no content.read
When the client sends GET /v1/article, GET /v1/article/12 and GET /v1/article/12/seo with that cookie
Then every response is 200 and none is 403
And a role repository failure while resolving the caller's role answers 500 "Internal server error"
And a request with no cookie never touches the role repository
Scenario Outline: The article list is the visible set
Given the articles of the Background
When <caller> sends GET /v1/article<query>
Then the ids returned are <ids>
And total is <total> and counts are <counts>
Examples:
| caller | query | ids | total | counts |
| no cookie | | 12, 15 | 2 | all 2, online 2, draft 0 |
| user 27 | | 12, 15 | 2 | all 2, online 2, draft 0 |
| user 9 Writer | | 10, 11, 12, 13, 14, 15, 16, 17 | 8 | all 8, online 2, draft 6 |
| user 22 (read own) | | 12, 15, 16 | 3 | all 3, online 2, draft 1 |
| user 22 (read own) | ?status=draft | 16 | 1 | all 3, online 2, draft 1 |
| user 22 (read own) | ?status=online| 12, 15 | 2 | all 3, online 2, draft 1 |
| user 21 (read all) | ?status=draft | 10, 11, 13, 14, 16, 17 | 6 | all 8, online 2, draft 6 |
And counts.suggestions and a ?seo= filter consider only the visible articles
And the ids are ordered by the existing default sort
Scenario Outline: Reading an article's SEO follows the same visibility
When <caller> sends GET /v1/article/<id>/seo
Then the result is <result>
Examples:
| caller | id | result |
| no cookie | 12 | 200 |
| no cookie | 10 | 404 Article not found |
| user 22 (read own) | 16 | 200 |
| user 22 (read own) | 10 | 404 Article not found |
| user 9 Writer | 10 | 200 |
Scenario Outline: The SEO check is a read of a visible article
When <caller> sends POST /v1/article/<id>/seo/check with body {"excerpt": "A short summary"}
Then the result is <result>
Examples:
| caller | id | result |
| user 27 | 12 | 403 missing_permission, header content.read |
| user 22 (read own) | 16 | 200 |
| user 22 (read own) | 12 | 200 |
| user 22 (read own) | 10 | 404 Article not found |
| user 9 Writer | 10 | 200 |
| user 9 Writer | 999 | 404 Article not found |
Scenario Outline: Creating an article needs content.create for the article type
When <caller> sends POST /v1/article with body {"title": "A new draft"}
Then the result is <result>
Examples:
| caller | result |
| user 21 Contributor | 201, is_online false, stored created_by 21 |
| user 22 Reader own | 201, is_online false, stored created_by 22 |
| user 9 Writer | 201, is_online false, stored created_by 9 |
| user 25 Typed A | 201, update:article=none does not affect create |
| user 24 Publisher own | 403 missing_permission, header content.create, nothing stored |
| user 26 Typed B | 403 missing_permission, header content.create, nothing stored |
| user 27 | 403 missing_permission, header content.create, nothing stored |
Scenario Outline: Updating and deleting an article follow the scope of the article
Given the articles of the Background
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 21 Contributor | PATCH /v1/article/10 {"body": "x"} | 200 |
| user 21 Contributor | PATCH /v1/article/11 {"body": "x"} | 403 content.update, article 11 unchanged |
| user 21 Contributor | PATCH /v1/article/14 {"body": "x"} | 403 content.update |
| user 21 Contributor | PATCH /v1/article/999 {"body": "x"} | 404 Article not found |
| user 21 Contributor | PATCH /v1/article/11 {"nothing": 1} | 400 apperr.BodyErrorMessage, as before |
| user 21 Contributor | DELETE /v1/article/10 | 403 content.delete, before any lookup (also for id 999) |
| user 9 Writer | PATCH /v1/article/13 {"body": "x"} | 200 |
| user 9 Writer | DELETE /v1/article/11 | 403 content.delete |
| user 23 Editor | PATCH /v1/article/13 {"body": "x"} | 200 |
| user 23 Editor | DELETE /v1/article/13 | 403 content.delete (created by user 3) |
| user 5 Admin | DELETE /v1/article/13 | 204 |
| user 3 Owner | PATCH /v1/article/10 {"body": "x"} | 200 |
| user 24 Publisher own | PATCH /v1/article/17 {"body": "x"} | 403 content.update (publish does not imply update), before any lookup |
| user 27 | PATCH /v1/article/12 {"body": "x"} | 403 content.update |
And a refused call changes no field, records no audit event and creates no author or site side effect, including when the body also changes site_id, author_ids or the slug
And an allowed own update keeps created_by unchanged
Scenario: An own-scope user can delete only their own article
Given user 23 is the Editor with content.delete=own and article 18 (draft) was created by user 23
When user 23 sends DELETE /v1/article/18 and then DELETE /v1/article/12
Then the first answers 204 and the article is gone and one article.delete audit event is recorded
And the second answers 403 missing_permission with X-Missing-Permission "content.delete" and article 12 is still stored
Scenario Outline: Publish is its own permission
Given the articles of the Background
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 9 Writer | PATCH /v1/article/11/publish | 403 content.publish, article 11 is still a draft |
| user 9 Writer | PATCH /v1/article/12/unpublish | 403 content.publish, article 12 is still online |
| user 21 Contributor | PATCH /v1/article/10/publish | 403 content.publish (own article, but publish is none) |
| user 23 Editor | PATCH /v1/article/11/publish | 200, is_online true, one article.publish audit event |
| user 23 Editor | PATCH /v1/article/12/unpublish | 200, is_online false, one article.unpublish audit event |
| user 24 Publisher own | PATCH /v1/article/17/publish | 200, is_online true |
| user 24 Publisher own | PATCH /v1/article/17/unpublish | 200, is_online false |
| user 24 Publisher own | PATCH /v1/article/10/publish | 403 content.publish, article 10 unchanged |
| user 24 Publisher own | PATCH /v1/article/999/publish | 404 Article not found |
| user 5 Admin | PATCH /v1/article/10/publish | 200 |
| user 27 | PATCH /v1/article/12/publish | 403 content.publish |
Scenario: Saving a draft without the publish permission
Given user 9 (Writer: content.update=all, no content.publish) and draft article 11
When user 9 sends PATCH /v1/article/11 with {"body": "new text"}
Then the status is 200, the body holds the new text and is_online false
And the article is still a draft
And the server answers no copy for this: "Ask an editor to publish." is shown by the SPA, never produced by the API
Scenario: Update never changes the status
Given user 9 and draft article 11
When user 9 sends PATCH /v1/article/11 with {"body": "x", "is_online": true}
Then the status is 200 and the article is still a draft
And user 9 sends PATCH /v1/article/12 with {"body": "x"} on the online article and it stays online
Scenario: A new article is always a draft whatever the role
Given user 3 (Owner) sends POST /v1/article with {"title": "Owner draft", "is_online": true}
Then the status is 201 and is_online is false
Scenario: A refused publish leaves no trace
Given user 9 sends PATCH /v1/article/11/publish
Then the response is 403 missing_permission and no audit event is recorded and the article's updated_at is unchanged
Scenario Outline: Entries follow the read scope
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 21 Contributor | GET /v1/content-type/events/entry | 200, entries 1, 2, 3, 4, 6, 7, 8, total 7 |
| user 22 (read own) | GET /v1/content-type/events/entry | 200, entry 6 only, total 1 |
| user 22 (read own) | GET /v1/content-type/events/entry?page=1&size=5 | 200, entry 6, total 1, page 1, size 5 |
| user 22 (read own) | GET /v1/content-type/events/entry/6 | 200 |
| user 22 (read own) | GET /v1/content-type/events/entry/1 | 404 entry_not_found |
| user 22 (read own) | GET /v1/content-type/events/entry/3 | 404 entry_not_found (created_by 0) |
| user 27 | GET /v1/content-type/events/entry | 403 content.read, before any lookup |
| user 27 | GET /v1/content-type/events/entry/1 | 403 content.read |
| user 27 | GET /v1/content-type/nope/entry | 403 content.read |
| user 21 Contributor | GET /v1/content-type/nope/entry | 404 content_type_not_found |
| user 5 Admin | GET /v1/content-type/events/entry/2 | 200 |
And under scope own, a ?q= search, total, page and size are computed over the caller's own entries only
Scenario Outline: Entries follow the create, update and delete scope
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 21 Contributor | POST /v1/content-type/events/entry {"data": {...}} | 201, created_by 21 |
| user 27 | POST /v1/content-type/events/entry {"data": {...}} | 403 content.create, nothing stored |
| user 21 Contributor | PUT /v1/content-type/events/entry/1 {"data": {...}} | 200, created_by stays 21, updated_by 21 |
| user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update, entry 2 unchanged |
| user 21 Contributor | PUT /v1/content-type/events/entry/3 {"data": {...}} | 403 content.update |
| user 21 Contributor | PUT /v1/content-type/events/entry/999 {"data": {...}} | 404 entry_not_found |
| user 21 Contributor | PUT /v1/content-type/nope/entry/1 {"data": {...}} | 404 content_type_not_found |
| user 21 Contributor | PUT /v1/content-type/events/entry/2 {"data": {"title": ""}} | 403 content.update (scope is checked before field validation) |
| user 21 Contributor | DELETE /v1/content-type/events/entry/1 | 403 content.delete, before any lookup, entry 1 stored |
| user 9 Writer | PUT /v1/content-type/events/entry/2 {"data": {...}} | 200 |
| user 9 Writer | DELETE /v1/content-type/events/entry/2 | 403 content.delete |
| user 23 Editor | DELETE /v1/content-type/events/entry/8 | 204 |
| user 23 Editor | DELETE /v1/content-type/events/entry/2 | 403 content.delete, entry 2 stored |
| user 5 Admin | DELETE /v1/content-type/events/entry/2 | 204 |
| user 3 Owner | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 |
And no entry route requires content.publish
Scenario Outline: Typed grants replace the wildcard for their type
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 25 Typed A | PATCH /v1/article/10 {"body": "x"} | 403 content.update (update:article=none replaces update=all), before any lookup |
| user 25 Typed A | PUT /v1/content-type/events/entry/4 {"data": {...}} | 200 (update:events=own, entry created by 25) |
| user 25 Typed A | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update (update:events=own replaces update=all) |
| user 25 Typed A | PUT /v1/content-type/venues/entry/5 {"data": {...}} | 200 (wildcard update=all applies to venues) |
| user 26 Typed B | GET /v1/content-type/events/entry | 200 |
| user 26 Typed B | GET /v1/content-type/venues/entry | 403 content.read |
| user 26 Typed B | POST /v1/content-type/events/entry {"data": {...}} | 201 |
| user 26 Typed B | POST /v1/content-type/venues/entry {"data": {...}} | 403 content.create |
| user 26 Typed B | PUT /v1/content-type/events/entry/7 {"data": {...}} | 200 |
| user 26 Typed B | PUT /v1/content-type/events/entry/2 {"data": {...}} | 403 content.update |
| user 26 Typed B | GET /v1/article/10 | 404 Article not found (no article read grant: public view) |
| user 26 Typed B | GET /v1/article/12 | 200 |
| user 26 Typed B | POST /v1/article {"title": "A new draft"} | 403 content.create |
And the header is always the base key, never "content.update:events"
Scenario: An unknown type key falls under the wildcard or is refused
Given user 26 (typed grants for events only)
When user 26 sends GET /v1/content-type/venues/entry and GET /v1/content-type/nope/entry
Then both answer 403 content.read, with no lookup of the content type
And user 21, whose wildcard grants apply, gets 404 content_type_not_found on GET /v1/content-type/nope/entry
Scenario: Creating a content type with a built-in key is refused
Given user 5 (Admin)
When user 5 sends POST /v1/content-type with a valid body whose key is "article"
Then the status is 409 and the body is apperr.ContentTypeKeyTakenErrorMessage and nothing is stored
And the same holds for the keys "author", "site", "media" and "user"
And content types that already exist are not touched
Scenario: Admin and Owner pass every content check
Given the Admin and the Owner
Then they read, create, update, delete and publish every article and entry whatever its created_by
And they list every article including drafts, with counts all 8, online 2, draft 6
Scenario: Writer is effective on content
Given user 9 on the Writer role
Then Writer reads and updates every article and entry, and creates articles and entries
And Writer gets 403 content.delete on DELETE of an article or an entry, and 403 content.publish on publish and unpublish
And editing an online article keeps it online
Scenario: A session follows the role live for content
Given user 21 gets 403 on PATCH /v1/article/11 {"body": "x"}
When the Contributor role's grant becomes content.update=all in the repository
Then the next PATCH /v1/article/11 with the same cookie answers 200, with no re-login
And when the grant is then removed, the next PATCH answers 403 content.update
Scenario: An API key follows its creator's role and owns what its creator owns
Given a valid API key created by user 21
When the key sends POST /v1/article {"title": "Key draft"}
Then the stored article has created_by 21
And the key's PATCH on that article answers 200 and on article 11 answers 403 content.update
And the key's PATCH /v1/article/11/publish answers 403 content.publish
And when user 21's role_id is changed to the Editor role, the same key's PATCH /v1/article/11/publish answers 200 with no new key
Scenario: A user whose role cannot be resolved is refused on gated content routes
Given user 13 has role_id 99
When user 13 sends POST /v1/article, PATCH /v1/article/10 and GET /v1/content-type/events/entry
Then every response is 403 missing_permission
And a role repository failure answers 500 "Internal server error"
Scenario: Verification and token checks still come first
Given user 12 is not verified and holds the Admin role
When user 12 sends PATCH /v1/article/10 with {"body": "x"}
Then the status is 403 apperr.VerificationRequiredErrorMessage
And a stale token_version gives 401 and no cookie gives 401 on every gated content route
Scenario Outline: Media routes keep their scope behaviour
When <caller> sends <call>
Then the result is <result>
Examples:
| caller | call | result |
| user 9 Writer | DELETE /v1/media/1 | 403 media.delete |
| role with media.delete=own | DELETE /v1/media/1 | 403 media.delete (images record no creator, own fails closed) |
| role with media.delete=own | POST /v1/media/merge | 403 media.delete |
| role with media.delete=own | POST /v1/media/purge | 403 media.delete |
| role with media.delete=all | DELETE /v1/media/1 | ok |
| user 9 Writer | GET /v1/media/1/usage | ok |
| user 9 Writer | POST /v1/media | ok |
And GET /v1/media/{id}/usage keeps listing every referencing article and entry to any media.view holder, whatever the content scopes (not filtered in this slice)
Scenario: authors.edit keeps its 2a behaviour on the shared helper
Given the authors.edit Outline of the enforcement feature
Then every row gives the same result as before, now evaluated through GrantSet.ScopeFor
Scenario: /auth/me lists the effective grants
Given a custom role with a duplicate row content.update=own and content.update=all, and a hand-placed settings.delete_site=all
When the client sends GET /v1/auth/me
Then access.permissions holds content.update at own and no settings.delete_site
And typed grants are not listed in this slice
Scenario: Out of scope here
Then there is no /v1/role CRUD, no no-escalation rule, no role change or ownership transfer, no SPA change and no schedule route
And typed grants are written by slice 3 and exposed to the SPA by a later slice
And rows of deleted content types in typed grants are cleaned up by slice 3
@@ -0,0 +1,120 @@
# QA procedure: roles_permissions_content_scope
Package: `test` (black-box). Name new fakes uniquely, for example `contentScopeRoleRepo`, `contentScopeArticleRepo`, `contentScopeEntryRepo`.
Reuse the fakes and helpers of `test/roles_permissions_enforcement_support_test.go` (`mintCookie`, `assertGate`, the API key minting helper) where they fit.
Do not copy any `Role.Allows`, `Role ==` or hand-rolled own/all comparison into new code: the tests call the exported domain helper.
## Deferred to later slices
- 3: /v1/role CRUD, no-escalation, writing typed grants, cleanup of typed rows of deleted content types.
- 4: role change, invite with role, ownership transfer, removal of the legacy role column.
- 5-7: SPA (View only mark, "Ask an editor to publish.", disabled Delete). The SPA reacts to the status code and the X-Missing-Permission header only. /auth/me exposing typed grants.
- Not changed: GET /v1/media/{id}/usage still lists articles and entries for any media.view holder. media.delete own fails closed. Entry relation summaries are not gated by the related type.
## Design notes for the user
- docs/design is silent on Read=Own. The draft encodes the decision recorded in the feature Background ("visible = online OR covered" for articles, filtered list and 404 for entries). If the user chooses another option, edit the "Reading articles", "The article list is the visible set" and "Entries follow the read scope" tables.
- The design says "Publish hidden for roles without it; status shows Draft with Ask an editor to publish." The API only answers 403 content.publish. The copy is the SPA's.
- roles.md "Escalation blocked" talks about granting Publish: All when the editor holds only Own. That is slice 3.
- A custom content type with a built-in key is now refused at creation (409). Mention it in docs/design when the Schema screens are reviewed.
## Inventory (route, key, gate, after the load, Owner / Admin / Writer)
| Route | Key (type) | Gate before load | After the load | Owner / Admin / Writer |
|---|---|---|---|---|
| GET /v1/article | content.read (article) | none, public | visible set = online OR covered, filters items, total, counts, ?status, ?seo | all / all / all |
| GET /v1/article/{id}, /{id}/seo | content.read (article) | none, public | invisible gives 404 Article not found | all / all / all |
| POST /v1/article | content.create (article) | typed not none | created_by = caller, offline | ok / ok / ok |
| PATCH /v1/article/{id} | content.update (article) | typed not none | Permits(created_by) | ok / ok / ok |
| DELETE /v1/article/{id} | content.delete (article) | typed not none | Permits(created_by) | ok / ok / 403 |
| PATCH /v1/article/{id}/publish, /unpublish | content.publish (article) | typed not none | Permits(created_by) | ok / ok / 403 |
| POST /v1/article/{id}/seo/check | content.read (article) | typed not none | visible set, else 404 | ok / ok / ok |
| GET /v1/content-type/{key}/entry | content.read ({key}) | typed not none | own filters list | all / all / all |
| GET /v1/content-type/{key}/entry/{id} | content.read ({key}) | typed not none | own and not creator gives 404 entry_not_found | ok / ok / ok |
| POST /v1/content-type/{key}/entry | content.create ({key}) | typed not none | created_by = caller | ok / ok / ok |
| PUT /v1/content-type/{key}/entry/{id} | content.update ({key}) | typed not none | Permits(created_by) | ok / ok / ok |
| DELETE /v1/content-type/{key}/entry/{id} | content.delete ({key}) | typed not none | Permits(created_by) | ok / ok / 403 |
| POST /v1/content-type | schema.edit | as 2a | creating a key equal to a built-in key gives 409 content_type_key_taken | ok / ok / 403 |
| DELETE /v1/media/{id}, POST /v1/media/merge | media.delete | all only | n/a (no creator on images) | ok / ok / 403 |
| GET /v1/media, /{id}, /{id}/usage | media.view | as 2a | usage not filtered | ok / ok / ok |
| POST /v1/media, PATCH /v1/media/{id} | media.upload | as 2a | none | ok / ok / ok |
| PATCH /v1/author/{id} | authors.edit | as 2a | link-based own | as 2a |
Routes that no key gates after 2b among the content routes: none. Entry routes need no content.publish. No schedule route exists.
## Test infrastructure first
1. `contentScopeRoleRepo`: in-memory role `Repository`, seeded with the three built-ins through the same Go function the migration uses, plus the custom roles and users 21-27 of the feature Background. Supports editing grants and role_id between calls, a switchable failure, and call counters for `Get`, `GetByName` and `ListGrants`. Its `Get` returns the role with `Grants` filled, like the persistence repository.
2. User fake with a `Get` call counter (existing `mockUserRepo` plus a counter, or a wrapper). Users 3, 5, 9, 12 (unverified Admin), 13 (role_id 99), 20 (hand-placed owner-only), 21-27.
3. Article fake and entry fake seeded with the ids, states and created_by values of the Background. Both record writes, so "article unchanged", "entry stored" and "nothing stored" are asserted on the store. A recording `audit.Recorder` to assert "no audit event".
4. HTTP harness: `validator.InitValidator()`. Build `NewArticleRouter` and `NewContentTypeRouter` equivalents mounted on a fresh `chi.NewRouter()` mirroring `api/router.go`: `authtoken.Verifier`, `container.apiKeyAuth()` equivalent, the new optional-access middleware on the public article group, `Authenticator`, `RequireCurrentToken`, `RequireVerifiedUser`, the typed permission gate, and the handlers over real use cases wired to the fakes. No live server, no real SQLite, no real SMTP.
5. `assertGate(t, rec, key)` from 2a for the 403 contract. A helper `assertUnchanged(t, store, id, snapshot)`.
6. A helper `grantsOf("content.update=own", "content.update:events=none", ...)` parsing the row notation of the feature into `[]role.Grant`, so table rows read like the feature.
## A. Domain (no fakes needed)
7. `GrantSet.ScopeFor`: run every row of the "ScopeFor resolves wildcard and typed keys" Outline through `Role{Grants: ...}.GrantSet()`. Include the 101-character type key and "Bad Key".
8. `GrantSet.Permits`: the 7 rows of the "One helper decides own and all" Outline.
9. Duplicates: build the set from the rows in both orders, expect the same most restrictive scope. Include a toggle key stored at own (expect none) and an unknown scope string (expect none).
10. `ValidateGrants`: the 10 rows of the Outline; the duplicate error is the new sentinel `role.ErrDuplicateGrant`.
11. Owner-only: `Role{Locked: false, Grants: system.apply_updates=all}.GrantSet()` has no owner-only scope for any of the 5 owner-only keys. `Role{Locked: true}` keeps them. `BuiltInRoles()` Owner keeps all 34, Admin none of the 5.
12. Hygiene tests, in the style of `*_hygiene_test.go`:
- `internal/domain/role` imports only the stdlib.
- `Role` has no `Allows`, `AllowsFor`, `scopeOf` (reflect on method set, or grep the non-test Go).
- Non-test Go under `api/` and `internal/infrastructure/useCases` has no comparison of `CreatedBy` with a caller id, and no direct index into a `GrantSet`, outside `internal/domain/role`.
- `api/router.go` and `api/middleware` hold no string literal equal to a catalogue key (parse the Go file, compare string literals with `role.Catalogue()`).
- Drift: every catalogue key has an exported constant in `roleKeys.go`, and every constant is a catalogue key.
## B. Use cases (fakes only)
13. `CheckPermission` (typed variant) table: Owner, Admin, Writer, each custom role, an unresolvable role (`role.ErrMissingPermission` via `errors.Is`, key via `errors.As`), a failing repository (non-sentinel error), and a live grant change between two calls.
14. Counters: for one `Require` call, `ListGrants` is 0, `Get` (or `GetByName` for a legacy owner/admin) is 1. Same for `GetMyAccessUseCase.Get`.
15. Article use cases (get, list, update, update-is-online, delete, create, SEO check): table of caller grants × article × expected error or result, per the feature Outlines. Assert for refusals: error is `role.MissingPermissionError{Key: "content.update"}` and so on, the store is unchanged, the recorder has no event, and no site/author/image lookup happened (counters on those fakes).
16. Entry use cases (list, get, create, update, delete): same, including `created_by` unchanged on update, own-filtered list totals and `?q=` over the own set, and `entry.ErrEntryNotFound` for a non-owned read under own.
17. Order: scope check after the load and before validation: PUT with an invalid body for a non-owned entry gives the scope error, not `FieldError`.
18. `CreateContentType` with each of the 5 built-in keys returns `contenttype.ErrKeyTaken`.
19. `GetMyAccess`: effective grants (duplicates normalised, owner-only dropped on unlocked roles, typed rows not listed).
## C. HTTP
20. Gate and scope matrices: run every row of each Scenario Outline of the feature. One test per Outline, subtests named after the row. Reset the stores for each row.
21. Contract: every 403 row via `assertGate` (header is the base key), no side effect, no audit event.
22. Public article routes: no cookie, unverified Admin (user 12), user 27, user 13 (role_id 99), API key. Assert 200/404 per the Outline and that no 403 is ever returned. Role repo failure gives 500; an anonymous request leaves the role repo counters at 0.
23. List: the 7 rows with items, total, counts. Assert `counts.suggestions` and `?seo=` consider only visible articles (add a suggestions-bearing draft owned by someone else, hidden from user 22).
24. Publish: all rows, plus the audit assertions (one `article.publish` / `article.unpublish`, none on refusal), plus "update never changes status" (`is_online` in the PATCH body ignored) and "new article is a draft".
25. Typed grants: the 13 rows, plus unknown type under wildcard (404) and under typed-only grants (403, no content type lookup counter).
26. Live role: edit grants and `role_id` between requests with the same cookie and the same API key.
27. API key: article created through the key has `created_by` of the creator.
28. Order: unverified 403, stale token 401, no cookie 401 before the permission gate on the authed article and entry routes.
29. Loads per request: with the counting fakes, a gated session request performs at most 1 user `Get` and exactly 1 role `Get`, 0 `ListGrants`; with an API key at most 2 user `Get`. Run on one article route and one entry route.
30. Owner-only: user 20 with a hand-placed `system.apply_updates=all` gets 403 on `POST /v1/system/update/check`; `/auth/me` omits it.
31. Media rows of the Outline and the "usage not filtered" assertion.
32. Regression: run the 2a Outline "Existing owner and admin behaviour does not regress", the authors.edit Outline and the activity/api-key scope Outlines unchanged.
## Existing tests that must change (found by grep; recheck with `go vet -tags nofrontend ./test/...`)
| File (lines) | Change |
|---|---|
| test/roles_permissions_foundation_test.go:38-53 | `Role.Allows` scenarios move to `GrantSet.ScopeFor`/`Permits`. The "unknown key" row keeps asserting `ErrUnknownPermission` from `Lookup` |
| test/role_hardening_test.go:202-212, 250-277 | `Allows`/`AllowsFor` rows move to `GrantSet`. Line 212 becomes the Owner role's grant set holding `system.apply_updates` |
| test/roles_permissions_enforcement_test.go:37-38, 83-85, 146-171 | The hand-made custom role with `system.apply_updates` now gets `ErrMissingPermission` (line 83-85 flips). `TestGrantSet` uses catalogue keys, not "a", "b", "c". `NewGrantSet` becomes `Role.GrantSet()` |
| test/roles_permissions_enforcement_http_test.go:343 | Custom role `grantsOf("system.apply_updates")` on `POST /v1/system/update/check` flips from ok to 403 |
| test/roles_permissions_enforcement_support_test.go:79; test/roles_permissions_enforcement_scope_test.go:252, 275, 297-346; test/api_keys_test.go:178-179 | `role.NewGrantSet(...)` call sites move to `Role{...}.GrantSet()` (owner-only keys need `Locked: true` in the fixture) |
| tests that build the article or entry routers by hand (test/article_publishing_http_test.go, edit_article_body_http_test.go, articles_list_http_test.go, articles_pagination_api_http_test.go, public_article_read_test.go, public_article_listing_test.go, article_body_format_test.go, article_body_unlimited_http_test.go, article_title_rules_test.go, seo_data_article_http_test.go, authors_backend_articles_http_test.go, delete_article_keeps_images_test.go, remove_legacy_article_image_alias_http_test.go, not_found_errors_test.go, stale_token_invalidation_http_test.go, site_articles_acceptance_test.go, custom_content_types_support_http_test.go, relation_field_type_http_test.go, media_field_type_http_test.go, activity_log_producers_http_test.go) | Mount the new middleware and give the role repository to the fixture. Users with legacy role "" or "editor" now get `missing_permission` on writes: give them a role_id (Writer, Editor-like custom role or Admin). Public draft visibility assertions: unverified and no-grant callers see online only |
| test/custom_content_types_test.go (and usecases) | Add the built-in key rows (409). Entry use case calls take the caller grants |
## Existing features to amend or mark superseded
Add a "superseded by roles_permissions_content_scope" note or edit in place:
- roles_permissions_enforcement: the Background clause "enforcement honours any grant it finds, including an owner-only key...", the Outline row `system.apply_updates=all | POST /v1/system/update/check | ok`, the scenario "Articles and entries are not gated in this slice", and its QA "Deferred" 2b line.
- roles_permissions_foundation: the Background line `Role.Allows(key, owned bool)`, the three `Allows` scenarios, and the sentence "per-type overrides ... deferred to slice 3" (the read side is implemented here, writing stays in slice 3).
- article_publishing, articles_list, articles_pagination, public_article_read, public_article_listing, edit_article_body, article_body_format, article_title_rules, delete_article_keeps_images, not_found_errors, seo_data_backend, seo_output_backend, seo_sites_articles_integration (draft visibility now depends on content.read).
- custom_content_types, custom_content_types_support, relation_field_type, media_field_type (entry routes are role-gated, built-in keys reserved).
- Matching `.qa.md` files for each of the above.
## Manual verification
- Start `task dev:api` on a copy of a migrated database. As the Writer: publish returns 403, saving a draft works and stays a draft. As the Admin: publish works.
- With two accounts on a custom role holding content.update=own, confirm account B gets 403 on A's article and entry, and A can edit them.
- Set a typed grant by editing `role_permission` in the database copy (`content.update:article` with scope none) and confirm article edits return 403 while entry edits still work. This is the only way to try typed grants until slice 3.
- Confirm no password or secret appears in any scenario. Env vars are referenced by name only (JWT_SECRET).
@@ -29,6 +29,7 @@ Feature: Roles and permissions enforcement: permission checks replace the owner
answers 500 "Internal server error"
And enforcement honours any grant it finds, including an owner-only key placed on a custom role by hand. Refusing to
grant owner-only keys belongs to slice 3 (ValidateGrants at write time)
(superseded by roles_permissions_content_scope: owner-only keys are effective only on a locked role, so a hand-placed one is ignored)
And the users, tokens and cookies are the ones of the foundation feature: user 3 Owner, user 5 Admin, user 9 Writer,
all verified with a valid jwt cookie and token_version 0
And routes with no obvious catalogue key reuse existing keys: media rename = media.upload; purge settings GET = settings.view,
@@ -194,7 +195,7 @@ Feature: Roles and permissions enforcement: permission checks replace the owner
| media.view=all | POST /v1/media | 403 |
| apikeys.create=all | GET /v1/api-key | 403 |
| settings.edit_general=all | PATCH /v1/media/purge-settings | ok |
| system.apply_updates=all | POST /v1/system/update/check | ok |
| system.apply_updates=all | POST /v1/system/update/check | 403 system.apply_updates (superseded by roles_permissions_content_scope: owner-only keys count only on a locked role) |
| (no grants) | GET /v1/author | 403 |
Scenario: A user whose role cannot be resolved is refused everywhere that is gated
@@ -367,6 +368,7 @@ Feature: Roles and permissions enforcement: permission checks replace the owner
Scenario: Articles and entries are not gated in this slice
Given user 20 has a custom role with no content grants
Then the article and entry routes answer exactly as before this slice (own/all, publish and draft are slice 2b)
And this scenario is superseded by roles_permissions_content_scope: articles and entries are gated by the content.* keys
Scenario Outline: Existing owner and admin behaviour does not regress
Given the caller is <role>
+1 -1
View File
@@ -5,7 +5,7 @@ Do not copy the old `Role ==` checks into new code.
## Deferred to later slices
- 2b: own/all enforcement after loading an article or entry, publish separate from update, Draft behaviour. Articles and entries stay ungated here.
- 2b: own/all enforcement after loading an article or entry, publish separate from update, Draft behaviour. Articles and entries stay ungated here. (Superseded: implemented by roles_permissions_content_scope, which also makes owner-only keys effective only on a locked role.)
- 3: /v1/role CRUD, no-escalation, ValidateGrants at write time, per-type override keys.
- 4: role change, invite with a role, transfer ownership, removal of the legacy role column.
- SPA (sidebar, read-only pages, 403 toasts) is out of scope. It reacts to the status code only.
@@ -13,7 +13,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
- 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
- the pure function Role.Allows(key, owned bool) bool (superseded by roles_permissions_content_scope: Role.Allows and AllowsFor are removed, a role is evaluated through Role.GrantSet(), ScopeFor and Permits)
And the catalogue is (kind: S = scoped none/own/all, B = on/off, "owner-only" = Owner only):
| group | key | kind | owner-only |
| content | content.read | S | no |
@@ -51,7 +51,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
| system | system.edit_retention | B | yes |
| system | system.transfer_ownership | B | yes |
And content permissions are stored under wildcard keys meaning "all content types"; per-type
overrides of the form content.<action>:<type_key> are deferred to slice 3
overrides of the form content.<action>:<type_key> are deferred to slice 3 (the read side is implemented by roles_permissions_content_scope; writing them stays in slice 3)
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,
@@ -75,7 +75,7 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
authtoken.Verifier, container.apiKeyAuth(), authtoken.Authenticator, RequireCurrentToken and
RequireVerifiedUser. It has no role check yet
Scenario Outline: Role.Allows evaluates scope and ownership
Scenario Outline: Role.Allows evaluates scope and ownership (superseded by roles_permissions_content_scope: the same rows are decided by GrantSet.Permits)
Given a role whose grant for "<key>" is "<scope>"
When Allows("<key>", owned=<owned>) is evaluated
Then the result is <allowed>
@@ -90,12 +90,12 @@ Feature: Roles and permissions foundation: catalogue, built-in roles, backfills
| content.create | all | false | true |
| schema.view | none | false | false |
Scenario: A key absent from the role is not allowed
Scenario: A key absent from the role is not allowed (superseded by roles_permissions_content_scope: decided by GrantSet.Permits)
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
Scenario: An unknown key is rejected, never silently allowed (superseded by roles_permissions_content_scope: decided by GrantSet.Permits)
Given a role with grants for every catalogue key at all
When Allows("content.explode", owned=true) is evaluated
Then the result is false
+1 -1
View File
@@ -25,7 +25,7 @@ The "Owner-only items" state in docs/design/screens/roles.md disables View roles
## 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.
4. (Superseded by roles_permissions_content_scope: the rows now run through `GrantSet.Permits` and `GrantSet.ScopeFor`.) 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).
+1
View File
@@ -1,4 +1,5 @@
Feature: SEO data backend, article and site search fields, unique slugs and media usage
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
As an editor or the owner of a RenewCMS installation
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: SEO data backend (slice 1 of 4)
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Spec: `features/seo_data_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. The 409 `slug_taken` assertion checks the plain-text body `slug_taken` and the `X-Slug-Suggestion` header.
+1
View File
@@ -1,4 +1,5 @@
Feature: SEO output backend, resolved SEO object, non-persisting check, list status and sitemap
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
As a frontend developer or the RenewCMS back-office editor
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: SEO output backend (slice 2 of 4)
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
Spec: `features/seo_output_backend.feature`. Go tests only, in `test/` (package `test`), run with `task test`. No live server, no real SMTP, no real SQLite file outside the temp-dir repository-contract harness. Call `validator.InitValidator()` before any handler call. Error assertions compare the plain-text body to the exact token or constant named in the spec. Success bodies are JSON. Assert exact key sets by decoding into `map[string]any` and comparing sorted key lists.
@@ -1,4 +1,5 @@
Feature: SEO round, slice 4: Sites defaults, Articles search status, Integration "Search and sharing" and suggestion dismissal
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
As the owner of a RenewCMS install, and as anyone who edits articles or builds a frontend on the API
@@ -1,5 +1,7 @@
# QA: SEO round, slice 4 (Sites defaults, Articles search status, Integration, suggestion dismissal)
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
> Superseded in part by roles_permissions_enforcement: the owner and admin 403 clauses now answer 403 "missing_permission" with the header X-Missing-Permission, decided by the caller's role permissions. An Admin can now invite and write sites, and a role without the needed permission is refused on routes that were open to every verified user.
SPA slice only. No Go change here: the API of `features/seo_data_backend.feature` and `features/seo_output_backend.feature` already exists, and `counts.suggestions` is covered by the httptest part of `features/articles_pagination_api.qa.md`, so this file has no httptest part. A wrong contract is reported as a backend defect.
@@ -1,4 +1,5 @@
Feature: A password reset invalidates every jwt issued before it, and a cancelled registration keeps doing so
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS
I want a jwt minted before a user resets their password to stop working the moment the reset
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: stale_token_invalidation
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box), extending `test/user_test.go` (`mockUserRepo`) and reusing the
router/handler wiring pattern from `test/account_verification_http_test.go` and
`test/reset_password_http_test.go` (or equivalent).
@@ -1,4 +1,5 @@
Feature: Protected article routes and the offline-inclusive listing require a verified account
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS
I want every protected article route, and the "see offline articles too" behavior of
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: verified_account_required
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box), extending `test/article_test.go` (`mockArticleRepo`) and
`test/user_test.go` (`mockUserRepo`). This slice's router mount touches both the article and auth
routers, so the HTTP-level checks live in a new `test/verified_account_required_http_test.go`.
+1
View File
@@ -1,4 +1,5 @@
Feature: Domain not-found errors on repository writes, image attachment and deletes
Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
As the maintainer of RenewCMS
I want an update, an image attachment or a delete against an unknown article, user or image id
+2
View File
@@ -1,5 +1,7 @@
# QA procedure: write_not_found_errors
> Superseded in part by roles_permissions_content_scope: the article routes are gated by the content.* permissions of the caller's role (content.create, content.update, content.delete and content.publish; a refusal is 403 "missing_permission" with the header X-Missing-Permission), and content.read decides which drafts show: every draft at all, only the caller's own drafts at own, none otherwise. A draft the caller may not read answers 404 "Article not found", and a role that cannot be resolved is treated as holding no permission.
Package: `test` (black-box, extending `article_test.go` / `user_test.go` / `image_test.go` / `not_found_errors_test.go`).
## Setup shared by every case