334 lines
17 KiB
Gherkin
334 lines
17 KiB
Gherkin
Feature: Capabilities, translations, publish rules, delete and story move
|
|
An article read tells the editor what the caller may do with the version and which readable sibling versions exist. Publishing respects disabled languages. Deleting the last version removes its story, and a whole article can be deleted at once, atomically. A story moves to another site as a whole, atomically, never split. Nothing reveals a version the caller cannot read.
|
|
|
|
Background:
|
|
Given error responses are plain text, with extra data in headers
|
|
And site "journal" (id 1) has enabled languages en (default), fr, de, ar and disabled language es
|
|
And site "atelier" (id 2) has enabled languages en (default), fr, de
|
|
And story 5 in journal holds article 10 (en, online, created_by 1, slug renovation-costs), 12 (fr, offline, 1, couts), 11 (de, offline, 2, kosten) and 13 (ar, online, 3, takalif)
|
|
And "owner" holds every content permission at scope all
|
|
And "writer1" (user 1) holds content.create, content.read, content.update, content.publish and content.delete at scope own, and content.read at scope all for online articles only
|
|
And "reader" holds content.read at scope all only
|
|
And "restricted" holds content.create at scope all and content.read at scope own
|
|
And "deleter" holds content.delete at scope all and content.read at scope own
|
|
|
|
Scenario: Capabilities of the owner
|
|
When the owner sends GET /v1/article/12
|
|
Then "capabilities" is ["read","edit","publish","delete","create_translation"]
|
|
|
|
Scenario Outline: Capabilities per caller and version
|
|
When <caller> sends GET /v1/article/<id>
|
|
Then "capabilities" is <capabilities>
|
|
|
|
Examples:
|
|
| caller | id | capabilities |
|
|
| writer1 | 12 | ["read","edit","publish","delete","create_translation"] |
|
|
| writer1 | 11 | ["read","create_translation"] |
|
|
| reader | 10 | ["read"] |
|
|
| a user with content.update and content.read all | 12 | ["read","edit"] |
|
|
| a user with content.publish and content.read all | 12 | ["read","publish"] |
|
|
| anonymous | 10 | ["read"] |
|
|
|
|
Scenario: Translations list readable versions only
|
|
When writer1 sends GET /v1/article/10
|
|
Then "translations" has entries for 10, 12 and 13 and none for 11
|
|
And each entry has id, lang, title, is_online, language_enabled, excluded_from_public, source_article_id and capabilities
|
|
And no entry, count, placeholder or total names the hidden version
|
|
|
|
Scenario: Translations are ordered default language first, then by tag
|
|
When the owner sends GET /v1/article/12
|
|
Then the translations are ordered en, ar, de, fr
|
|
And the version being read is included
|
|
|
|
Scenario: Disabled languages are marked
|
|
Given language "fr" is disabled on journal
|
|
When the owner sends GET /v1/article/10
|
|
Then the fr entry has "language_enabled": false and is still listed
|
|
|
|
Scenario: A single-version article lists itself
|
|
Given article 20 is alone in story 8
|
|
When the owner sends GET /v1/article/20
|
|
Then "translations" has one entry, article 20 itself
|
|
|
|
Scenario: Anonymous reads expose nothing about siblings
|
|
When an anonymous client sends GET /v1/article/10
|
|
Then "translations" is []
|
|
And "capabilities" is ["read"]
|
|
And the response has no "missing_langs", "excluded_from_public", "source_unavailable" or "partial_view"
|
|
And the public shape is otherwise unchanged
|
|
|
|
Scenario: The source on the read
|
|
Given article 14 was created from article 10 at content_revision 4
|
|
When the owner sends GET /v1/article/14
|
|
Then "source" is {"article_id":10,"revision":4}
|
|
When article 10 is deleted or unreadable to the reader
|
|
Then "source" is null
|
|
And a version created empty has "source": null
|
|
|
|
Scenario Outline: Missing languages are the enabled languages without a readable version
|
|
When <caller> sends GET /v1/article/10
|
|
Then "missing_langs" is <missing>
|
|
|
|
Examples:
|
|
| caller | missing |
|
|
| owner | [] |
|
|
| restricted | ["de","fr"] |
|
|
| reader | [] |
|
|
|
|
Scenario: A version in a disabled language or without language is left out of public surfaces
|
|
Given article 14 (es, offline, created by restricted) is in story 5 and es is disabled on journal
|
|
And article 30 has lang "" and its own story
|
|
When the owner sends GET /v1/article/14
|
|
Then "excluded_from_public" is true
|
|
And the translations entry of 14 has "excluded_from_public": true and the entry of 10 has false
|
|
And GET /v1/article/30 has "excluded_from_public": true
|
|
And GET /v1/article/10 has "excluded_from_public": false
|
|
|
|
Scenario: A deleted source and an unreadable source read the same
|
|
Given article 14 tracks article 12 at content revision 2
|
|
When the owner sends GET /v1/article/14
|
|
Then "source" is {"article_id":12,"revision":2} and "source_unavailable" is false
|
|
When the restricted user, who cannot read 12, sends GET /v1/article/14
|
|
Then "source" is null and "source_unavailable" is true
|
|
When article 12 is deleted and the owner sends GET /v1/article/14
|
|
Then "source" is null and "source_unavailable" is true
|
|
|
|
Scenario: Translations name the source they track only when it is readable
|
|
Given article 14 tracks article 12
|
|
When the owner sends GET /v1/article/10
|
|
Then the translations entry of 14 has "source_article_id": 12 and the entry of 10 has null
|
|
When the restricted user sends GET /v1/article/14
|
|
Then the translations entry of 14 has "source_article_id": null
|
|
|
|
Scenario: Partial view follows the read scope of the caller only
|
|
When the owner sends GET /v1/article/10
|
|
Then "partial_view" is false
|
|
When the restricted user sends GET /v1/article/13
|
|
Then "partial_view" is true, whether or not the story holds a version the restricted user cannot read
|
|
|
|
Scenario Outline: Publishing respects the language configuration
|
|
Given a valid jwt cookie for a user holding content.publish
|
|
When the client sends PATCH /v1/article/<id>/publish
|
|
Then the response status is <status>
|
|
And <effect>
|
|
|
|
Examples:
|
|
| id | situation | status | effect |
|
|
| 12 | fr enabled | 200 | "is_online": true |
|
|
| 15 | es disabled, offline version | 422 | the body is "language_disabled" |
|
|
| 16 | es disabled, already online | 200 | unchanged, "is_online": true |
|
|
| 30 | unassigned lang "" | 200 | "is_online": true |
|
|
|
|
Scenario: Unpublish and edit stay available in a disabled language
|
|
Given fr is disabled on journal and article 12 is online
|
|
When the owner sends PATCH /v1/article/12/unpublish
|
|
Then the response status is 200
|
|
When the owner sends PATCH /v1/article/12 with {"body":"x"}
|
|
Then the response status is 200
|
|
|
|
Scenario: Publishing one version leaves the others alone
|
|
When the owner publishes article 12
|
|
Then articles 10, 11 and 13 keep their publication and updated_at
|
|
|
|
Scenario: Re-enabling a language allows publishing again
|
|
Given es is re-enabled on journal
|
|
When the owner sends PATCH /v1/article/15/publish
|
|
Then the response status is 200
|
|
|
|
Scenario Outline: Existing publish errors are unchanged
|
|
When <request>
|
|
Then the response status is <status>
|
|
And the response body is <message>
|
|
|
|
Examples:
|
|
| request | status | message |
|
|
| the owner publishes article 9999 | 404 | Article not found |
|
|
| the owner publishes article "abc" | 400 | apperr.IdUint32ErrorMessage |
|
|
| an anonymous client publishes article 12 | 401 | Unauthorized |
|
|
| the reader publishes article 12 | 403 | missing_permission |
|
|
|
|
Scenario: Deleting one version keeps the story
|
|
When the owner sends DELETE /v1/article/12
|
|
Then the response status is 204
|
|
And articles 10, 11 and 13 and story 5 remain
|
|
And the translations no longer list fr
|
|
|
|
Scenario: A deleted language can be created again
|
|
Given article 12 (fr) was deleted
|
|
When the owner sends POST /v1/article/10/translations with lang "fr"
|
|
Then the response status is 201
|
|
And fr of journal still counts as in use while a deleted fr version exists, per features/site_languages.feature
|
|
|
|
Scenario: Deleting the last version removes the story
|
|
Given story 8 holds only article 20
|
|
When the owner sends DELETE /v1/article/20
|
|
Then the response status is 204 and story 8 is gone
|
|
And creating a translation from story 8 answers 404 "Article not found"
|
|
|
|
Scenario: Deleting the last of several versions
|
|
When the owner deletes 10, 11, 12 and 13 one after another
|
|
Then story 5 exists after each of the first three deletions
|
|
And story 5 is gone after the fourth
|
|
|
|
Scenario: Delete is checked per version
|
|
When writer1 sends DELETE /v1/article/11
|
|
Then the response status is 403 and the body is "missing_permission"
|
|
And article 11 remains
|
|
|
|
Scenario: A story with a hidden version
|
|
Given writer1 deletes the last version it can see while an unreadable version remains
|
|
Then the story remains
|
|
And nothing in the response mentions the other version
|
|
|
|
Scenario: Deleting an article removes every version and the article
|
|
When the owner sends DELETE /v1/story/5
|
|
Then the response status is 204 with an empty body
|
|
And articles 10, 11, 12 and 13 and story 5 are gone
|
|
And one article.delete event per version is recorded with params.lang and params.story_id 5
|
|
|
|
Scenario: Versions the caller cannot read are deleted with the article
|
|
When the deleter, who reads 10 and 13 only, sends DELETE /v1/story/5
|
|
Then the response status is 204
|
|
And articles 11 and 12 are deleted too
|
|
|
|
Scenario: Deleting an article needs delete permission on every version
|
|
When writer1, whose content.delete is at scope own, sends DELETE /v1/story/5
|
|
Then the response status is 403, the body is "missing_permission" and X-Missing-Permission is "content.delete"
|
|
And every version remains
|
|
And the answer is the same whether or not the story holds a version writer1 cannot read
|
|
|
|
Scenario: A failed delete removes nothing
|
|
Given the database fails while the versions are deleted
|
|
When the owner sends DELETE /v1/story/5
|
|
Then the response status is 500 and the body is "Failed to delete article"
|
|
And every version and story 5 remain
|
|
|
|
Scenario: An unknown or unreadable article answers the same not found
|
|
When the owner sends DELETE /v1/story/9999
|
|
Then the response status is 404 and the body is "Article not found"
|
|
When the deleter sends DELETE /v1/story/77, whose only version is a draft by another user
|
|
Then the response status is 404 and the body is "Article not found"
|
|
And that version remains
|
|
|
|
Scenario: An invalid id is refused
|
|
When the owner sends DELETE /v1/story/abc
|
|
Then the response status is 400 with the uint32 id message
|
|
|
|
Scenario: Move a story
|
|
When the owner sends POST /v1/story/5/move with {"site":"atelier"}
|
|
Then the response status is 200
|
|
And the body is {"story_id":5,"site":"atelier","site_id":2,"article_ids":[10,12,11,13]}
|
|
And all four articles have site_id 2
|
|
And their slugs, lang, publication, content and authors are unchanged
|
|
And each moved version's revision rises by 1 and content_revision is unchanged
|
|
|
|
Scenario: A move requires every language to be enabled at the destination
|
|
Given atelier has no "ar" language
|
|
When the owner sends POST /v1/story/5/move with {"site":"atelier"}
|
|
Then the response status is 422 and the body is "language_not_enabled"
|
|
And the header X-Missing-Languages is "ar", readable versions only
|
|
And nothing is moved
|
|
|
|
Scenario: A language disabled at the destination counts as missing
|
|
Given de is listed but disabled on atelier
|
|
When the owner moves story 5 to atelier
|
|
Then the response status is 422
|
|
And X-Missing-Languages includes "de"
|
|
|
|
Scenario: A language disabled at the source still needs the destination
|
|
Given fr is disabled on journal and enabled on atelier
|
|
When the owner moves story 5 to atelier
|
|
Then the move succeeds
|
|
Given fr is disabled on journal and missing on atelier
|
|
When the owner moves story 5 to atelier
|
|
Then the response status is 422 and the body is "language_not_enabled"
|
|
And X-Missing-Languages is "fr"
|
|
|
|
Scenario: Address conflict at the destination
|
|
Given atelier has an fr article with slug "couts" and an ar article with slug "takalif"
|
|
And the caller can read 10, 11 and 12 but not 13
|
|
When the caller moves story 5 to atelier
|
|
Then the response status is 409 and the body is "slug_conflict"
|
|
And the header X-Conflicting-Articles is 12
|
|
And article 13 is neither named nor counted
|
|
And nothing is moved
|
|
|
|
Scenario: Every conflict of the story is checked
|
|
Given two versions of the story conflict at the destination
|
|
When a caller who can read both moves the story
|
|
Then no version moves
|
|
And both readable conflicting versions are named in X-Conflicting-Articles
|
|
|
|
Scenario Outline: A move needs update permission on every version, readable or not
|
|
When <caller> sends POST /v1/story/5/move with {"site":"atelier"}
|
|
Then the response status is <status>
|
|
And the response body is <message>
|
|
And no version is moved
|
|
|
|
Examples:
|
|
| caller | status | message |
|
|
| an anonymous client | 401 | Unauthorized |
|
|
| writer1 | 403 | missing_permission |
|
|
| a user without content.update | 403 | missing_permission |
|
|
| a user who can read no version | 404 | Article not found |
|
|
|
|
Scenario: Hidden versions never leak through the move
|
|
Given the caller cannot read some versions of story 5
|
|
Then a 403 body and headers, a 422 X-Missing-Languages and a 409 X-Conflicting-Articles name only readable versions
|
|
And a 200 article_ids lists only readable versions
|
|
|
|
Scenario Outline: Move validation
|
|
When the owner sends <request>
|
|
Then the response status is <status>
|
|
And the response body is <message>
|
|
|
|
Examples:
|
|
| request | status | message |
|
|
| POST /v1/story/5/move with {"site":"nowhere"} | 404 | site_not_found |
|
|
| POST /v1/story/5/move with {} | 400 | apperr.BodyErrorMessage |
|
|
| POST /v1/story/5/move with malformed JSON | 400 | apperr.BodyErrorMessage |
|
|
| POST /v1/story/9999/move with {"site":"atelier"} | 404 | Article not found |
|
|
| POST /v1/story/abc/move with {"site":"atelier"} | 400 | apperr.IdUint32ErrorMessage |
|
|
|
|
Scenario: Moving to the current site
|
|
When the owner sends POST /v1/story/5/move with {"site":"journal"}
|
|
Then the response status is 200
|
|
And nothing changes
|
|
And no activity event is recorded
|
|
|
|
Scenario: The move is atomic and revalidated inside the transaction
|
|
When a version is edited, a language is disabled at the destination, or an address is taken between the pre-check and the write
|
|
Then the move is refused with the matching error and no version moves
|
|
When a write fails on the third version
|
|
Then the first two are rolled back
|
|
|
|
Scenario: PATCH site_id cannot split a story
|
|
When the owner sends PATCH /v1/article/10 with {"site_id":2}
|
|
Then the response status is 409 and the body is "story_move_required"
|
|
And nothing is changed
|
|
|
|
Scenario: PATCH site_id counts readable versions only
|
|
Given a story holds three versions and a caller reads only one of them
|
|
When that caller sends PATCH site_id on the readable version
|
|
Then content.update is required on every version of the story, readable or not, and the whole story moves under the story move rules
|
|
And every version, hidden ones included, ends up on the destination site in one transaction
|
|
And a language, address or slug refusal rolls everything back and names readable versions only
|
|
And a caller lacking update on any version gets 403 "missing_permission" with nothing naming a hidden version
|
|
And a caller who reads two versions gets 409 "story_move_required"
|
|
|
|
Scenario: PATCH site_id on a single-version story applies the move rules
|
|
Given story 8 holds only article 20 in lang "fr"
|
|
When the owner sends PATCH /v1/article/20 with {"site_id":2}
|
|
Then the article moves when fr is enabled at atelier
|
|
And otherwise the response is 422 "language_not_enabled"
|
|
And an unassigned article (lang "") moves without a language check
|
|
|
|
Scenario: Activity events carry the language
|
|
Then publish, unpublish, edit and delete events hold params lang, omitted for an unassigned article
|
|
And a move records one "article.edit" event per moved version with the site change and lang
|
|
And a refused move records one failed event with a plain reason naming no hidden version
|
|
|
|
Scenario: Persistence portability
|
|
Given the story delete rule, the (story_id, lang) re-creation after a delete and the atomic move with a unique (site_id, lang, slug) conflict
|
|
Then they pass on SQLite, MySQL 8.4, MariaDB 11 and PostgreSQL
|