293 lines
17 KiB
Gherkin
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
|