121 lines
15 KiB
Markdown
121 lines
15 KiB
Markdown
# 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).
|