Files
RenewCMS/features/sites_backend.feature
T

641 lines
30 KiB
Gherkin

Feature: Sites backend, the owner manages sites and every article belongs to exactly one
As the owner of a RenewCMS installation
I want to create, edit, delete and choose a default among several sites, and assign each article to one
So that a single back-office can hold the content of more than one website
Background:
Given the spec of record is docs/design/screens/sites.md, docs/design/states.md (site rows), docs/design/routes.md and docs/design/sites-authors-slices.md
And this slice is backend only: no SPA, no change to docs/design
And a Site has id (uint32), name, key, base_url, default_social_image_id (nullable), default_robots ("index" or "noindex"), locale (nullable), is_default and article_count, in snake_case JSON
And exactly one site is the default at any time, enforced by the use case inside a transaction and not by a partial index
And every article has a site_id referencing an existing site
And the routes live under /v1/site, mounted inside the JWT group: GET /, POST /, PATCH /{id}, PATCH /{id}/default, DELETE /{id}?reassign_to={id}
And GET /v1/site/ accepts a session or an API key; every write is session-only and goes through RequireOwner, like PATCH /v1/settings
And PATCH /v1/article/{id} with site_id stays callable with an API key
And error responses are plain text from http.Error whose body is exactly the code token: site_key_taken, site_has_articles, default_site, key_immutable, invalid_site_url, invalid_locale, site_not_found, image_not_found, use_set_default, invalid_reassign
And a non-owner write answers 403 with the existing RequireOwner body "Only the owner can do this." (apperr.OwnerRequiredErrorMessage), with no owner_only token
And a request body that cannot be decoded answers 400 with apperr.BodyErrorMessage ("The request cannot be processed due to a mismatch in the format of the body."), a malformed id answers 400 with apperr.IdUint32ErrorMessage, and a validate-tag failure answers 400 with the validator error text, like POST /v1/article
And a missing article answers 404 "Article not found" (apperr.ArticleNotFoundErrorMessage)
And the roles are "owner" and "admin" only, and a "verified" account is one with is_verified true
And success responses are JSON with snake_case fields
And request validation goes through validate tags and validator.Validate.Struct
And the key matches ^[a-z0-9]+(-[a-z0-9]+)*$ with 2 to 32 characters, the name has 1 to 100 characters, the locale matches ^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$ and an empty or null locale means none
And base_url must be https, except http for localhost, 127.0.0.1 and [::1], with no path, query or userinfo; a trailing slash is stripped; uniqueness is not enforced
And handlers reach sites only through use cases and domain interfaces, and no comment is added anywhere
Rule: Reading sites
Scenario: The list puts the default site first and then orders by name
Given the sites "Zeta" (key "zeta"), "Alpha" (key "alpha") and "Main" (key "main", default)
When a verified account sends GET /v1/site/
Then the status is 200
And the body lists "Main", "Alpha", "Zeta" in that order
And every site carries article_count
Scenario Outline: Who can read the list
Given at least one site exists
When GET /v1/site/ is sent with <credentials>
Then the status is <status>
Examples:
| credentials | status |
| the owner's jwt cookie | 200 |
| a verified admin's jwt cookie | 200 |
| a valid API key | 200 |
| an unverified account's jwt cookie | 403 |
| no credentials | 401 |
Scenario: A site without image or locale returns nulls
Given the site "Blog" has no default social image and no locale
When a verified account sends GET /v1/site/
Then "Blog" has default_social_image_id null and locale null
Rule: Creating a site is owner only
Scenario: The owner creates a site
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "default_robots": "noindex", "locale": "fr-CA"}
Then the status is 201
And the body is the site with a new id, is_default false, article_count 0 and the given values
And the site is stored
Scenario: Optional fields default
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com"}
Then the site has default_robots "index", locale null, default_social_image_id null and is_default false
Scenario: A new site can become the default at creation
Given the site "Main" is the default
When the owner sends POST /v1/site/ with {"name": "Shop", "key": "shop", "base_url": "https://shop.example.com", "is_default": true}
Then "Shop" is the default and "Main" is not
And exactly one site is the default
Scenario Outline: The base URL is normalised
When the owner creates a site with base_url "<sent>"
Then the stored base_url is "<stored>"
Examples:
| sent | stored |
| https://shop.example.com/ | https://shop.example.com |
| https://Shop.Example.com | https://shop.example.com |
| http://localhost:5173/ | http://localhost:5173 |
Scenario Outline: The base URL must be https except loopback and carry no path
When the owner creates a site with base_url "<sent>"
Then the status is <status>
And the body is "<body>"
Examples:
| sent | status | body |
| https://shop.example.com | 201 | |
| http://localhost:8080 | 201 | |
| http://127.0.0.1:8080 | 201 | |
| http://[::1]:8080 | 201 | |
| http://shop.example.com | 400 | invalid_site_url |
| https://shop.example.com/blog | 400 | invalid_site_url |
| https://shop.example.com/?a=1 | 400 | invalid_site_url |
| https://user@shop.example.com | 400 | invalid_site_url |
| ftp://shop.example.com | 400 | invalid_site_url |
| shop.example.com | 400 | invalid_site_url |
Scenario Outline: The key format
When the owner creates a site with key "<key>"
Then the status is <status>
Examples:
| key | status |
| shop | 201 |
| my-shop-2 | 201 |
| ab | 201 |
| a | 400 |
| Shop | 400 |
| -shop | 400 |
| shop- | 400 |
| my--shop | 400 |
| my_shop | 400 |
| aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 400 |
| (empty) | 400 |
Scenario: A taken key is refused
Given the site with key "shop" exists
When the owner sends POST /v1/site/ with key "shop"
Then the status is 409
And the body is "site_key_taken"
And no site is added
Scenario Outline: Other fields are validated
When the owner sends POST /v1/site/ with <field> <value>
Then the status is <status>
And the body is "<body>"
And no site is added
Examples:
| field | value | status | body |
| name | (empty) | 400 | the validator error text |
| name | 101 characters | 400 | the validator error text |
| default_robots | "follow" | 400 | the validator error text |
| locale | "english" | 400 | invalid_locale |
| locale | "fr_FR" | 400 | invalid_locale |
| default_social_image_id | 999 (no such image) | 400 | image_not_found |
Scenario: A malformed body is refused
When the owner sends POST /v1/site/ with the body "{not json"
Then the status is 400
And the body is apperr.BodyErrorMessage
And no site is added
Scenario Outline: A valid locale is accepted and an empty one clears it
When the owner creates a site with locale <locale>
Then the status is 201
And the stored locale is <stored>
Examples:
| locale | stored |
| "en" | "en" |
| "fr-CA" | "fr-CA" |
| "zh-Hant-TW" | "zh-Hant-TW" |
| "" | null |
| null | null |
Rule: Editing a site is owner only
Scenario: The owner edits a site
Given the site 2 "Shop" with key "shop"
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "base_url": "https://boutique.example.com", "default_robots": "noindex", "locale": "fr"}
Then the status is 200
And the body is the site with the new values and the key "shop"
Scenario: Absent fields are untouched
Given the site 2 has locale "fr" and default_robots "noindex"
When the owner sends PATCH /v1/site/2 with {"name": "Boutique"}
Then only the name changed
Scenario: The default social image is set and cleared
Given the image 5 exists
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": 5}
Then the site 2 has default_social_image_id 5
When the owner sends PATCH /v1/site/2 with {"default_social_image_id": null}
Then the site 2 has default_social_image_id null
Scenario Outline: The key never changes
Given the site 2 has key "shop"
When the owner sends PATCH /v1/site/2 with {"key": "<key>"}
Then the status is <status>
And the body is "<body>"
And the key of site 2 is still "shop"
Examples:
| key | status | body |
| shop | 200 | |
| store | 400 | key_immutable |
Scenario: A rejected key change leaves every other field unchanged
Given the site 2 "Shop"
When the owner sends PATCH /v1/site/2 with {"name": "Boutique", "key": "store"}
Then the status is 400 with body "key_immutable"
And the name of site 2 is still "Shop"
Scenario: The default flag is not editable here
When the owner sends PATCH /v1/site/2 with {"is_default": true}
Then the status is 400
And the body is "use_set_default"
And the default site did not change
Scenario Outline: Editing is validated like creation
When the owner sends PATCH /v1/site/2 with <field> <value>
Then the status is 400
And the body is "<body>"
And the site 2 is unchanged
Examples:
| field | value | body |
| base_url | "http://shop.example.com" | invalid_site_url |
| base_url | "https://shop.example.com/x" | invalid_site_url |
| locale | "fr_FR" | invalid_locale |
| default_social_image_id | 999 | image_not_found |
| name | (empty) | the validator error text |
| default_robots | "follow" | the validator error text |
Scenario: A missing site is not found
When the owner sends PATCH /v1/site/999 with {"name": "X"}
Then the status is 404
And the body is "site_not_found"
Scenario: A no-op edit records nothing
When the owner sends PATCH /v1/site/2 with the values it already has
Then the status is 200
And no activity event is recorded
Scenario: A malformed id is refused
When the owner sends PATCH /v1/site/abc with {"name": "X"}
Then the status is 400
And the body is the uint32 id format error
Rule: The default site
Scenario: The owner makes another site the default
Given "Main" is the default and "Shop" is not
When the owner sends PATCH /v1/site/2/default
Then the status is 200
And the body is "Shop" with is_default true
And "Main" is no longer the default
Scenario: Setting the current default is a no-op
When the owner sends PATCH /v1/site/1/default on the current default
Then the status is 200
And no activity event is recorded
Scenario: A missing site cannot become the default
When the owner sends PATCH /v1/site/999/default
Then the status is 404
And the body is "site_not_found"
Scenario: The swap is atomic
Given the repository fails while flagging the new default
When the owner sends PATCH /v1/site/2/default
Then the previous default is still the only default
Scenario: New articles start on the new default
Given "Shop" became the default
When an article is created without a site
Then its site_id is the id of "Shop"
Rule: Deleting a site is owner only
Scenario: A site without articles is deleted
Given the site 2 has no articles and is not the default
When the owner sends DELETE /v1/site/2
Then the status is 204
And the site 2 no longer exists
Scenario: A site with articles needs a destination
Given the site 2 has 3 articles
When the owner sends DELETE /v1/site/2
Then the status is 409
And the body is "site_has_articles"
And the site and its articles are unchanged
Scenario: The articles move in one transaction
Given the site 2 has 3 articles and the site 3 exists
When the owner sends DELETE /v1/site/2?reassign_to=3
Then the status is 204
And the 3 articles now have site_id 3
And the site 2 no longer exists
Scenario: Reassign and delete are all-or-nothing
Given the repository fails while deleting the site 2
When the owner sends DELETE /v1/site/2?reassign_to=3
Then the articles of the site 2 still have site_id 2
And the site 2 still exists
Scenario: The articles are moved by one repository operation
When the owner deletes a site with articles and a valid reassign_to
Then the use case calls a single repository operation that moves the articles and deletes the site
Scenario Outline: An unusable destination
Given the site 2 has 3 articles and is not the default
When the owner sends DELETE /v1/site/2?reassign_to=<reassign_to>
Then the status is <status>
And the body is "<body>"
And nothing changed
Examples:
| reassign_to | status | body |
| 999 | 404 | site_not_found |
| 2 | 400 | invalid_reassign |
| abc | 400 | invalid_reassign |
| -1 | 400 | invalid_reassign |
Scenario: The default site cannot be deleted
When the owner sends DELETE /v1/site/1 on the default site
Then the status is 403
And the body is "default_site"
Scenario: The default check comes before the articles check
Given the default site has 5 articles
When the owner sends DELETE /v1/site/1 without reassign_to
Then the status is 403 with body "default_site"
Scenario: A missing site is not found
When the owner sends DELETE /v1/site/999
Then the status is 404
And the body is "site_not_found"
Scenario: An unused reassign_to is ignored
Given the site 2 has no articles
When the owner sends DELETE /v1/site/2?reassign_to=3
Then the status is 204
And the site 3 is unchanged
Rule: Only the owner writes
Scenario Outline: Other roles and keys are refused
When <request> is sent with <credentials>
Then the status is 403
And the body is "<body>"
And no site changed
Examples:
| request | credentials | body |
| POST /v1/site/ | a verified admin's jwt cookie | Only the owner can do this. |
| PATCH /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
| PATCH /v1/site/2/default | a verified admin's jwt cookie | Only the owner can do this. |
| DELETE /v1/site/2 | a verified admin's jwt cookie | Only the owner can do this. |
| POST /v1/site/ | a valid API key | This action needs a real session. |
| PATCH /v1/site/2 | a valid API key | This action needs a real session. |
| PATCH /v1/site/2/default | a valid API key | This action needs a real session. |
| DELETE /v1/site/2 | a valid API key | This action needs a real session. |
Scenario: The role is checked before the body
When an admin sends POST /v1/site/ with the body "{not json"
Then the status is 403 with body "Only the owner can do this."
Rule: Seed and migration
Scenario: A fresh database gets one default site from PUBLIC_URL
Given an empty database and PUBLIC_URL "https://www.example.com/blog"
When database.Migrate runs with that PUBLIC_URL
Then one site exists with name "Default", key "default", base_url "https://www.example.com", default_robots "index", locale null and is_default true
Scenario Outline: The seed follows the base URL rule
Given an empty database
When database.Migrate runs with PUBLIC_URL "<public_url>"
Then the seeded base_url is "<base_url>"
Examples:
| public_url | base_url |
| https://www.example.com/ | https://www.example.com |
| https://www.example.com/a/b | https://www.example.com |
| http://www.example.com | https://www.example.com |
| http://localhost:8080 | http://localhost:8080 |
| http://127.0.0.1:8080/x | http://127.0.0.1:8080 |
Scenario: An unparseable PUBLIC_URL stops the migration
Given an empty database
When database.Migrate runs with PUBLIC_URL "://not a url"
Then it returns an error naming PUBLIC_URL
And no site is created
Scenario: Existing articles are backfilled
Given a database from before this slice holding 4 articles
When database.Migrate runs
Then all 4 articles have the seeded default site as site_id
Scenario: The seed runs once
Given the sites table already holds a site
When database.Migrate runs again with another PUBLIC_URL
Then no site is added and no site is changed
Scenario: PUBLIC_URL is not used afterwards
Given the owner edited the default site base_url
When the process restarts with the same PUBLIC_URL
Then the default site keeps the owner's base_url
Scenario: It works on every database driver
When the repository contract suite runs
Then the site repository behaves the same on SQLite, MySQL and PostgreSQL
And the SQLite schema test accounts for the new table and column
Rule: Articles belong to a site
Scenario: A new article starts on the default site
Given "Main" is the default site with id 1
When the owner sends POST /v1/article with {"title": "Spring release", "body": "Hello"}
Then the stored article has site_id 1
And the article is offline
Scenario Outline: Article reads carry site_id
Given the article 8 belongs to the site 2
When <request> is sent
Then the article in the response has "site_id": 2
Examples:
| request |
| GET /v1/article/8 |
| GET /v1/article/ (each item of items) |
| POST /v1/article |
| PATCH /v1/article/8/publish |
| PATCH /v1/article/8 |
Scenario: The list filters by site key
Given the site "shop" has 2 articles and "main" has 3
When a verified account sends GET /v1/article/?site=shop
Then only the 2 articles of "shop" are listed
Scenario Outline: The filter composes with the other parameters
Given the site "shop" has articles of mixed status and titles
When GET /v1/article/?site=shop&<params> is sent
Then every returned article belongs to "shop" and satisfies <params>
Examples:
| params |
| status=online |
| status=draft |
| q=spring |
| sort=title:asc |
| size=5&page=2 |
| status=draft&q=launch |
Scenario Outline: Unknown or empty site keys
When GET /v1/article/?site=<key> is sent
Then the status is <status>
And <result>
And the counts stay global
Examples:
| key | status | result |
| nope | 200 | the list is empty |
| (empty) | 400 | the body is apperr.BodyErrorMessage |
Scenario: An anonymous or unverified list is filtered by site and shows online articles only
Given "shop" has one online and one offline article
When a caller without a verified account sends GET /v1/article/?site=shop
Then only the online article is returned
Scenario: An offline article is still not found publicly
Given the article 9 is offline on any site
When a caller without a verified account sends GET /v1/article/9
Then the status is 404
And the body is "Article not found"
Scenario: An article moves to another site
Given the article 8 is on the site 1 and the site 2 exists
When the user sends PATCH /v1/article/8 with {"site_id": 2}
Then the status is 200
And the article has site_id 2
Scenario Outline: Any role that can edit the article may choose its site
Given the article 8 is editable by <credentials>
When PATCH /v1/article/8 is sent with {"site_id": 2} and <credentials>
Then the status is 200
Examples:
| credentials |
| the owner's jwt cookie |
| a verified admin's jwt cookie |
| a valid API key |
Scenario: The body and the site change together
When the user sends PATCH /v1/article/8 with {"title": "New", "site_id": 2}
Then both the title and the site_id are updated
Scenario: An update without site_id keeps the site
Given the article 8 is on the site 2
When the user sends PATCH /v1/article/8 with {"title": "New"}
Then the article is still on the site 2
Scenario Outline: An unusable site_id is refused
When the user sends PATCH /v1/article/8 with {"site_id": <value>}
Then the status is <status>
And the body is "<body>"
And the article is unchanged
Examples:
| value | status | body |
| 999 | 404 | site_not_found |
| 0 | 400 | the validator error text |
| -1 | 400 | apperr.BodyErrorMessage |
| "a" | 400 | apperr.BodyErrorMessage |
Scenario: A site cannot be removed from an article
When the user sends PATCH /v1/article/8 with {"site_id": null}
Then the status is 400
And the body is apperr.BodyErrorMessage
And the article is unchanged
Scenario: Moving to the same site records nothing
Given the article 8 is on the site 2
When the user sends PATCH /v1/article/8 with {"site_id": 2}
Then the status is 200
And no activity event is recorded
Scenario Outline: An empty or unknown update is refused
When the user sends PATCH /v1/article/8 with <body>
Then the status is 400
And the body is apperr.BodyErrorMessage
And the article is unchanged
Examples:
| body |
| {} |
| {"colour": "x"} |
Scenario: is_online is independent of the site
Given the article 8 is online
When the user moves it to the site 2
Then it is still online
Scenario: Articles keep working after a site rename
Given the article 8 is on the site 2
When the owner renames the site 2
Then GET /v1/article/8 still returns the article with site_id 2
Scenario: A site change is recorded in article.edit with site names
Given the article 8 "Spring release" is on "Main" and "Shop" exists
When the user sends PATCH /v1/article/8 with {"site_id": 2}
Then one event "article.edit" exists with changes [{"field": "site", "from": "Main", "to": "Shop"}]
Rule: Media usage by default social images
Scenario: An image used as a default social image is counted as used
Given the image 5 is the default social image of the site 2 and appears in no article
When a verified account looks up the usage of the image 5
Then used_in counts the site and the image is not "Unused"
Scenario: Deleting the image clears the site's field
When the owner deletes the image 5
Then the status is 204
And the site 2 has default_social_image_id null
Scenario: Merging copies repoints the site
Given the image 6 is a copy of the image 5 and the site 2 uses the image 6
When the copies are merged into the image 5
Then the site 2 has default_social_image_id 5
And the merge response reports sites_updated 1
Scenario: Purge repoints sites the same way
Given a purge group whose kept image is 5 and whose duplicate 6 is used by the site 2
When the purge runs
Then the site 2 has default_social_image_id 5
And the purge result reports sites_updated 1
Scenario: A failed purge group leaves the site pointing at an existing image
Given the purge of a group fails midway
Then every site default_social_image_id refers to an image that still exists
Scenario: The usage lookup stays an array and later gains additive keys
When a verified account looks up the usage of an image
Then the response is a JSON array whose article items keep id, title and published
And features/seo_data_backend.feature adds type and fields to every item and lists authors and sites, without removing any key
Rule: Every site write is recorded in the activity log
Scenario Outline: A successful write is recorded once after the change
When the owner performs <request>
Then one event exists with action "<action>", category "sites", target {"type": "site", "id": "<id>", "name": "<name>"}, outcome "success", actor the owner and source "web"
And the event was recorded after the change was applied
Examples:
| request | action | id | name |
| POST /v1/site/ for "Shop" | site.create | 2 | Shop |
| PATCH /v1/site/2 renaming it | site.edit | 2 | Shop |
| PATCH /v1/site/2/default | site.set_default | 2 | Shop |
| DELETE /v1/site/2 | site.delete | 2 | Shop |
Scenario Outline: A refused write is recorded as failed with a plain reason
When the owner performs <request> which is refused with <code>
Then one event exists with action "<action>", outcome "failed" and failure_reason "<reason>"
Examples:
| request | code | action | reason |
| POST /v1/site/ with a taken key | site_key_taken | site.create | A site with that key already exists. |
| PATCH /v1/site/2 changing the key | key_immutable | site.edit | The key of a site cannot be changed. |
| DELETE /v1/site/1 on the default site | default_site | site.delete | The default site cannot be deleted. |
| DELETE /v1/site/2 with articles | site_has_articles | site.delete | The site still has articles. |
| POST /v1/site/ with an invalid base URL | invalid_site_url | site.create | The site address is not valid. |
| PATCH /v1/site/2 with an invalid locale | invalid_locale | site.edit | The locale is not valid. |
| PATCH /v1/site/2 with is_default | use_set_default | site.edit | Use the default site action to change the default. |
| DELETE /v1/site/2 reassigned to itself | invalid_reassign | site.delete | The destination site is not valid. |
| POST /v1/site/ when the repository fails with "db is locked" | 500 | site.create | Something went wrong on our side. Please try again. |
| PATCH /v1/site/2/default when the repository fails | 500 | site.set_default | Something went wrong on our side. Please try again. |
Scenario Outline: Nothing is recorded when there is nothing to attribute
When <request> is sent
Then no activity event is recorded
Examples:
| request |
| PATCH /v1/site/999 as the owner |
| DELETE /v1/site/999 as the owner |
| POST /v1/site/ with an unknown image (image_not_found) |
| POST /v1/site/ as an admin (403) |
| POST /v1/site/ with no credentials (401) |
| PATCH /v1/site/abc as the owner |
| POST /v1/site/ with a malformed body |
Scenario: A failing audit repository never changes the response
Given the audit repository fails on Append
When the owner sends POST /v1/site/ with valid values
Then the response equals that of a healthy repository
And the site was created
Scenario: Reads are never recorded
When GET /v1/site/ is sent
Then no activity event is recorded
Scenario: The key is never a change field
When the owner edits a site name, base URL, robots, locale and image
Then the changes use only the fields name, base_url, default_robots, locale and default_social_image
And no change has the field "key"
Scenario: The activity API lists site events
Given site events were recorded
When a verified account sends GET /v1/activity/?category=sites
Then the site events are returned with category "sites"
Rule: Documentation
Scenario: The docs describe the new surface
Then docs/api.md documents the /v1/site routes, their errors, article site_id, the ?site= filter and the media sites_updated field
And docs/configuration.md states that PUBLIC_URL seeds the first site once at migration
And docs/architecture.md mentions the site aggregate, its repository and its use cases