Files
RenewCMS/features/updates_release_feed.feature

293 lines
17 KiB
Gherkin

Feature: Updates part 1: signed releases, release feed and read-only update notification
As the owner of a RenewCMS site
I want to see when a newer signed release exists, and have admins see the same status read-only
So that I know when to update, while downloading and restarting arrive in later slices
Background:
Given slices 1 to 3 of Updates ship in one release, so no released build shows the command
"renewcms enable-browser-updates" before it exists (it is implemented in slice 3)
And every release is published as a Gitea prerelease with the six binaries, checksums.txt and checksums.txt.sig
And cmd/releasesign is a stdlib-only tool (crypto/ed25519, no new dependency) with the commands
keygen <private-key-path> [--pub-out <path>], sign <file> [--key <path>] [--pub <path>],
verify <file> [--pub <path>]
And keygen writes the private key to the path given and refuses a path inside the repository work tree
And sign and verify use the embedded public key unless --pub is given; tests always pass --pub with a throwaway key
And sign refuses to sign when the private key does not match the public key, naming the mismatch
And the public key is internal/infrastructure/update/release.pub, embedded with go:embed
And until the user runs task release:keygen and commits the real public key, release.pub holds the placeholder:
the base64 of 32 zero bytes (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=); an all-zero key is recognised
as the placeholder, and sign and verify refuse it with a clear message
And the real private key is generated by the user outside the repo; the coder never generates or commits it
And task release writes checksums.txt (SHA-256 of every binary) next to the binaries and signs it into
checksums.txt.sig with the private key at the path in the env var RELEASE_SIGNING_KEY
And task release fails clearly, before building anything, when RELEASE_SIGNING_KEY is unset, the file is missing,
the embedded key is the placeholder, or the private key does not match the embedded public key
And task release:keygen creates the one-time key pair and prints where to commit the public key
And docs/release-notes.md gains the publish step (attach checksums.txt and checksums.txt.sig with the six binaries),
docs/deployment.md and docs/configuration.md are updated, and nothing is published
And the domain package internal/domain/update (stdlib only) holds Release, ReleaseNotes, version comparison,
UpdateStatus, the ReleaseFeed interface, the StatusStore interface and the Err sentinels
And the Gitea adapter lives under internal/infrastructure/update and implements update.ReleaseFeed with
var _ update.ReleaseFeed = &X{}; it requests GET {UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases
(the list, not "latest")
And UPDATE_FEED_URL is in requiredEnvVars/envDefaults, .env.example and docs/configuration.md, defaulting to the repo base URL
And a JSON file store implements update.StatusStore and keeps the last result and checked_at in update-status.json
beside DB_FILE, shared by all owners and surviving a restart; no new env var is added
And GetUpdateStatusUseCase and CheckForUpdateUseCase depend only on the domain interfaces; handlers in
api/handlers/update receive them through NewHandler; api/dependecyInjection.go builds the adapters
And NewSystemRouter is mounted at /v1/system behind jwtauth.Verifier, jwtauth.Authenticator, RequireCurrentToken
and RequireVerifiedUser; POST /update/check also sits behind RequireOwner and a limiter of 5 checks per minute
per user_id (constants, not env vars, separate from the auth limiter) that answers 429 with an integer Retry-After
And GET /v1/system/update returns 200 with JSON of exactly these snake_case keys: current, latest, available,
release_url, notes {version, released_at, items}, checked_at, can_apply, state, progress, error, started_at
And release_url is the release's html_url, null when never checked; in slice 3 the "How to update" link is
repointed at the "Browser updates" section of docs/deployment.md
And in this slice can_apply is always false and state is always "idle"; never-checked values are latest "",
available false, release_url null, notes null, checked_at null, progress null, error null, started_at null
And versions compare numerically per segment, a final release beats a beta of the same number, and the
running build "dev" never reports an update
And release notes are the release body converted to a plain-text list of items
And the API returns raw tags; the SPA shows the newer release without its leading "v" ("Version 2.3.0-beta")
and the running version exactly as reported
And the download and restart endpoints are not mounted; ReleaseFeed, StatusStore and UpdateStatus are shaped so
slices 2 and 3 only add use cases
And errors are plain text from http.Error
Scenario Outline: Version comparison
Given the running version is "<current>" and the newest feed release is "<latest>"
When the versions are compared
Then available is <available>
Examples:
| current | latest | available |
| v2.2.0-beta | v2.3.0-beta | true |
| v2.5.0-beta | v2.5.0 | true |
| v2.5.0 | v2.5.0-beta | false |
| v2.9.0 | v2.10.0 | true |
| v2.5.0-beta | v2.5.0-beta | false |
| v2.5.0 | v2.4.9 | false |
| dev | v9.9.9 | false |
Scenario: The newest release is chosen from the list regardless of feed order
Given the feed list holds v2.4.0-beta, v2.5.0-beta and v2.3.0-beta, all marked prerelease
When the feed is read
Then the latest release is v2.5.0-beta
Scenario: Drafts are ignored
Given the feed list holds a draft release v9.0.0 and a published v2.5.0-beta
When the feed is read
Then the latest release is v2.5.0-beta
Scenario: Release notes become a plain-text list
Given a release body "## What's new\n- **Media** library\n- Fixed [caching](http://x) bug\n\n* Faster start"
When the notes are built
Then notes.items is ["Media library","Fixed caching bug","Faster start"]
And notes.released_at is the release's published date
Scenario: GET returns the idle shape, never applicable
Given a verified user, running version "v2.2.0-beta", and a stored result with latest "v2.3.0-beta",
html_url "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta" and checked_at 2026-09-30T09:41:00Z
When the client sends GET /v1/system/update
Then the status is 200 and the body has exactly the 12 keys
And current is "v2.2.0-beta", latest is "v2.3.0-beta", available is true, can_apply is false, state is "idle"
And release_url is the stored html_url and checked_at is "2026-09-30T09:41:00Z"
And the feed is not contacted
Scenario: GET before any check
Given no stored result
When a verified user sends GET /v1/system/update
Then the status is 200, available is false, latest is "", release_url, notes and checked_at are null, state is "idle"
Scenario Outline: GET is open to any verified user
Given the caller is <role> with a valid jwt cookie
When the client sends GET /v1/system/update
Then the status is 200
Examples:
| role |
| owner |
| admin |
Scenario: GET without a cookie
When the client sends GET /v1/system/update with no jwt cookie
Then the status is 401
Scenario: GET with a stale token
Given the jwt cookie was minted with a token version older than the account's
When the client sends GET /v1/system/update
Then the status is 401 and the body is apperr.UnauthorizedErrorMessage
Scenario: GET by an unverified account
When an unverified user with a valid cookie sends GET /v1/system/update
Then the status is 403 and the body is apperr.VerificationRequiredErrorMessage
Scenario: The owner checks and the result is stored
Given the fake feed returns v2.3.0-beta and the running version is "v2.2.0-beta"
When the owner sends POST /v1/system/update/check
Then the status is 200 with the same body shape as GET and available true
And the fake store holds latest "v2.3.0-beta" and checked_at at the check time
And a later GET returns the same result without contacting the feed
Scenario: A check with nothing newer
Given the feed returns v2.2.0-beta and the running version is "v2.2.0-beta"
When the owner sends POST /v1/system/update/check
Then the status is 200 and available is false
Scenario: A dev build never reports an update
Given the running version is "dev" and the feed returns v9.9.9
When the owner sends POST /v1/system/update/check
Then the status is 200 and available is false
Scenario: An admin cannot check
Given the caller is an admin
When the client sends POST /v1/system/update/check
Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage
And the feed is not contacted
Scenario: Feed failure keeps the last result
Given the store holds latest "v2.3.0-beta" checked at 2026-09-30T09:41:00Z and the feed returns an error
When the owner sends POST /v1/system/update/check
Then the status is 500
And the store still holds the previous result and checked_at
And no feed error detail appears in the body
Scenario: Check is rate limited per user
When the owner sends 6 checks within one minute
Then the first 5 answer 200 and the 6th answers 429 with apperr.TooManyRequestsErrorMessage and an integer Retry-After
And the feed is contacted 5 times
And a different owner is not limited by the first owner's checks
And the auth limiter's counters are untouched
Scenario: The stored result survives a restart
Given the JSON file store saved a result
When a new store is opened on the same file
Then the same latest, release_url and checked_at are returned
And a missing or corrupt file reads as "no result" and is not fatal
Scenario: Release signing round trip with a throwaway key
Given a key pair made by keygen at a path outside the repo
When sign is run on checksums.txt with that key and verify with its public key
Then verify exits 0
And after one byte of checksums.txt changes, verify exits non-zero with a clear message
And verify with a different public key exits non-zero
Scenario: keygen refuses a path inside the repository
When keygen is given a path inside the work tree
Then it exits non-zero, writes nothing and says why
Scenario: The placeholder public key is refused
Given release.pub holds the placeholder (32 zero bytes, base64)
When sign or verify uses the embedded key
Then it exits non-zero with a message that the placeholder key has not been replaced
Scenario: sign refuses a private key that does not match the public key
Given a private key whose public half differs from the public key in use
When sign is run
Then it exits non-zero naming the mismatch and writes no .sig file
Scenario Outline: task release refuses to sign
Given <condition>
When task release is run
Then it fails before building any binary with a message naming <what>
And nothing is published
Examples:
| condition | what |
| RELEASE_SIGNING_KEY is unset | RELEASE_SIGNING_KEY |
| RELEASE_SIGNING_KEY points to a missing file | the missing path |
| the embedded public key is the placeholder | the placeholder key |
| the private key does not match the public key | the key mismatch |
Scenario: Settings, owner, Up to date (route /settings)
Given the session role is "owner" and GET /v1/system/update resolves available false with checked_at set
When the owner opens /settings
Then the Updates card shows the status block with icon circle-check (success), title "You're up to date",
text "You have the latest version of RenewCMS." and a meta line "Last checked ..." built from checked_at
And the only button is "Check for updates" (refresh-cw, secondary)
And no Restart button, hint or progress is rendered
Scenario: Settings, owner, Checking
When the owner presses "Check for updates"
Then the block shows icon loader-circle, "Checking for updates…" / "This usually takes a few seconds."
in a polite live region
And the button reads "Checking…" and is disabled
And POST /v1/system/update/check is sent once
Scenario: Settings, owner, Check failed
Given POST /v1/system/update/check answers 500 or never reaches the server
Then the block shows icon wifi-off (warning), "Couldn't check for updates" /
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected."
and the meta "Last checked ..." from the kept result
And the only button is "Try again" (rotate-cw, primary)
Scenario: Settings, owner, newer version, not prepared
Given the session role is "owner" and GET /v1/system/update resolves available true, can_apply false, latest "v2.3.0-beta"
When the owner opens /settings
Then the block shows icon info, "Version 2.3.0-beta is available" /
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you."
And "For whoever manages the server" shows the one-line code "renewcms enable-browser-updates" with a Copy button
And the note "They run this once on the server. After that, Update and Restart appear here." shows
And the link "How to update" (arrow-up-right) opens release_url in a new tab
And no button other than Copy is rendered: not Update, not Restart, not even Check for updates
Scenario: Settings, owner, newer version, notes open
Given the same state with notes.items and notes.released_at 2026-09-24
When the owner activates "What's new in 2.3.0-beta" (a button with aria-expanded false then true)
Then "Released 24 Sep 2026" and the plain-text list show, and activating it again collapses them
And the notes are collapsed by default
Scenario: Settings, command copied
When the owner presses Copy
Then the button reads "Copied" with a check for 2 s, is announced, then reads "Copy" again
Scenario: Settings, admin view
Given the session role is "admin"
When GET /v1/system/update resolves available true with latest "v2.3.0-beta"
Then the card shows "Version 2.3.0-beta is available" /
"You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site." and no button
When it resolves available false
Then the card shows "You're up to date" / "Only the owner can update or restart the site." and no button
Scenario: Settings, Role changed (403)
Given an owner is on /settings and POST /v1/system/update/check answers 403
Then an error toast "Only the owner can do this" / "Ask the owner of this site to update RenewCMS." shows
And the card reloads GET /v1/system/update and renders the admin view
Scenario: Settings, Too many requests (429)
Given POST /v1/system/update/check answers 429 with Retry-After 42
Then the block shows icon info "Too many tries" / "Wait a moment before trying again."
And the button reads "Try again in 0:42", counts down each second and is re-enabled at zero
Scenario: A check runs on the first open after 24 hours, for the owner only
Given the owner opens /settings and checked_at is null or older than 24 hours
Then POST /v1/system/update/check is sent once automatically and the Checking state shows
When checked_at is under 24 hours old
Then no automatic check is sent
And an admin never sends one
Scenario: Home banner for the owner, update available
Given the owner is on /home and available is true with latest "v2.3.0-beta"
Then a region labelled "Update available" shows above the content in info tone with icon circle-arrow-up,
title "Version 2.3.0-beta is available",
text "Update it from Settings. It takes about 2 minutes and the site stays online.",
a "Go to Settings" button and a dismiss button
When the owner dismisses it
Then the banner is hidden and localStorage remembers the dismissed version
And it shows again when latest changes to a newer version
Scenario: Settings sidebar pill and mobile dot
Given the owner and available is true
Then the Settings sidebar item shows an "Update" pill, and on mobile the menu icon shows a dot read as "Update available"
And the pill stays after the banner is dismissed
Scenario: Admins and an up-to-date owner see no banner, pill or dot
Then an admin, or an owner with available false, sees no banner, pill or dot
Scenario: Pending states of slices 2 and 3
Then these states are listed as pending in the visual gate, never passing: Update available with its Update button
(and its notes-open frame), Downloading, Verifying, Ready to install, Restart confirm, Restart request failed,
Restarting, Restarting taking longer, Update done, Rolled back, Download failed, Verification failed,
Plain restart confirm, restarting and done, Update already running (409), and the Home "ready to install" banner
And the Update, Restart and progress elements are not rendered in any state of this slice