feat: update button (download, verify, stage)

This commit is contained in:
fsylvain committed 2026-10-01 18:13:45 +02:00
1 parent 834493a4f5
commit 70460e4170
41 files changed
+5345 -97

No files matched your search

+334
View File
@@ -0,0 +1,334 @@
Feature: Updates part 2: the Update button (download, verify, stage)
As the owner of a RenewCMS site
I want one button that downloads the new release, proves it is genuine, backs up the database and stages it
So that the later Restart (slice 3) installs a file that was already checked, while the site stays online
Background:
Given slices 1 to 3 of Updates ship in one release
And this slice changes nothing that is running: it never replaces the running binary and never restarts the site
And the domain package internal/domain/update (stdlib only) gains these interfaces, each faked in tests:
AssetFetcher (downloads one asset to a path, reporting bytes done and total),
SignatureVerifier (ed25519 check of checksums.txt against checksums.txt.sig with the embedded release.pub),
StagedBinaryRunner (runs <path> --version and returns its output),
DatabaseBackup (consistent copy of the SQLite file with VACUUM INTO, prune to the last 3),
HelperDetector (reports whether the slice 3 helper is installed),
JobStore (persists the update job record)
And update.Release gains Assets (name, download URL, size) read by the Gitea adapter, and the stored CheckResult keeps
them, so POST /v1/system/update/download never contacts the feed
And the use cases depend only on domain interfaces; handlers in api/handlers/update receive them through NewHandler;
only api/dependecyInjection.go builds the adapters
And the job runs in one background goroutine owned by the use case; the HTTP handler returns as soon as it is started
And POST /v1/system/update/download is mounted in NewSystemRouter behind jwtauth.Verifier, jwtauth.Authenticator,
RequireCurrentToken, RequireVerifiedUser, RequireOwner and its own limiter of 5 starts per minute per user_id
(constants, separate from the check limiter and the auth limiter) answering 429 with an integer Retry-After
And the checks on POST run in this order: no cookie or stale token (401), unverified (403), not owner (403),
limiter (429), can_apply false (409), a job running or already ready (409), available false (400),
pre-flight refusal (422), then 202
And errors are plain text from http.Error
And the data directory is the directory that holds DB_FILE; no new env var is added
And the job record is update-job.json in the data directory, shared by all owners and surviving a restart
And a job record found as "downloading" or "verifying" at startup (no goroutine owns it) reads as failed with
error "download"; a record "ready" whose staged binary is gone reads as idle
And the staged files live in <data dir>/updates/<tag>/: the binary, checksums.txt and checksums.txt.sig
And the asset to download is named renewcms_<tag>_<runtime.GOOS>_<runtime.GOARCH>, plus ".exe" on windows
And the job runs these steps in order: download checksums.txt, checksums.txt.sig and the binary; verify the
ed25519 signature of checksums.txt; verify the SHA-256 of the binary against its checksums.txt line; run the
staged binary with --version and require the output to equal the tag; back up the database; mark "ready"
And state is "downloading" during the downloads and "verifying" from the signature check until "ready"
(the version run and the backup happen in "verifying")
And the download policy, enforced in the production adapter: the URL must be https, its host (with port) must equal
the host of UPDATE_FEED_URL, and every redirect must satisfy the same rule; there is a size cap per file
(binary 200 MiB, checksums.txt 64 KiB, checksums.txt.sig 1 KiB) and an overall timeout of 10 minutes (named constants)
And in tests the policy is relaxed only through the adapter constructor, which takes the allowed host, an
"allow insecure scheme" option, a smaller cap and a shorter timeout; there is no env var or build flag that relaxes it
And GET /v1/system/update keeps its 12 keys; state is one of idle, downloading, verifying, ready, failed;
progress is {percent, downloaded_bytes, total_bytes} while downloading and null otherwise;
error is null, "download" or "verification" (a kind only, never technical text); started_at is the job start
And can_apply is true only for the owner when HelperDetector reports the helper installed; it is false for admins
And the real HelperDetector reads <data dir>/update-helper.json (the status file the slice 3 helper writes); nothing
writes it in this slice, so can_apply is false on real builds until slice 3
And disk errors and database backup failures end the job as failed with error "download"
And on any failure the staged files are deleted, the technical reason is logged server side, and no response carries it
And a failed record stays until the next job starts; ready stays until slice 3 consumes it
And the SPA shows the newer release without its leading "v" ("Version 2.3.0-beta") and the running version exactly as reported
And the Restart button in "Ready to install" is rendered but disabled in this slice: the board frame draws it as the
primary action, it has no handler, and slice 3 enables it; plain Restart (no update waiting) stays not rendered until slice 3
Scenario: Start the update, happy path
Given the owner, can_apply true, a stored result with latest "v2.3.0-beta" (running "v2.2.0-beta", available true)
and assets for the host platform, all served by the fake fetcher and signed with a throwaway key
When the owner sends POST /v1/system/update/download
Then the status is 202 and the body is the status JSON with state "downloading" and started_at set
And GET /v1/system/update then reports downloading with progress.total_bytes equal to the asset size
And when the job finishes GET reports state "ready", error null, progress null, latest "v2.3.0-beta"
And the staged binary, checksums.txt and checksums.txt.sig are in <data dir>/updates/v2.3.0-beta/
And a database backup exists in <data dir>/backups/ before state became "ready"
And the running binary and the database are unchanged
Scenario Outline: The asset name follows the platform
Given the runtime is <os>/<arch> and the release lists all six binaries
When the update starts
Then the fetcher is asked for "<asset>", "checksums.txt" and "checksums.txt.sig" only
Examples:
| os | arch | asset |
| linux | amd64 | renewcms_v2.3.0-beta_linux_amd64 |
| linux | arm64 | renewcms_v2.3.0-beta_linux_arm64 |
| darwin | arm64 | renewcms_v2.3.0-beta_darwin_arm64 |
| windows | amd64 | renewcms_v2.3.0-beta_windows_amd64.exe |
Scenario: Progress is reported in bytes
Given the fake fetcher has delivered 11 of 26 MiB
When any verified user sends GET /v1/system/update
Then state is "downloading" and progress is {percent: 42, downloaded_bytes: 11534336, total_bytes: 27262976}
And percent is the floor of done * 100 / total
Scenario: Every owner sees the same job
Given owner A started the job
When owner B and an admin send GET /v1/system/update
Then both read state "downloading" with the same progress and started_at
And the owner's can_apply is true and the admin's can_apply is false
Scenario: The job record survives a restart
Given the job is "ready" for "v2.3.0-beta" with its staged binary present
When a new status use case is built over the same data directory
Then GET reports state "ready"
And a record "downloading" at startup reads as state "failed", error "download"
And a record "ready" with the staged binary deleted reads as "idle"
And a missing or corrupt job file reads as "idle" and is not fatal
Scenario: Only the owner
When an admin sends POST /v1/system/update/download
Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage
And no job starts and the fetcher is not called
Scenario Outline: POST is guarded like the other system routes
When <caller> sends POST /v1/system/update/download
Then the status is <status>
And no job starts
Examples:
| caller | status |
| no jwt cookie | 401 |
| a stale token version | 401 |
| an unverified owner | 403 |
Scenario: Nothing to update
Given a stored result with available false, or no stored result
When the owner sends POST /v1/system/update/download
Then the status is 400 and no job starts and the fetcher is not called
Scenario: The server is not prepared
Given HelperDetector reports no helper
When the owner sends POST /v1/system/update/download
Then the status is 409 and no job starts
And GET reports can_apply false
And this holds even when available is false (409 before 400)
Scenario Outline: A second start while a job exists
Given the job state is <state>
When the owner sends POST /v1/system/update/download
Then the status is 409 and the existing job is untouched
Examples:
| state |
| downloading |
| verifying |
| ready |
Scenario: Two starts at once run one job
When two owners send POST /v1/system/update/download concurrently
Then exactly one answers 202 and the other 409
And the fetcher downloads each asset once
Scenario: Try again after a failure
Given the job state is "failed"
When the owner sends POST /v1/system/update/download
Then the status is 202, error returns to null, started_at is new and the old staged files are replaced
Scenario: Start is rate limited per user
When the owner sends 6 starts within one minute
Then the first 5 are answered by the checks above and the 6th is 429 with apperr.TooManyRequestsErrorMessage
and an integer Retry-After of at least 1
And a different owner is not limited, and the check limiter and auth limiter counters are untouched
Scenario Outline: 422 is only a pre-flight refusal
Given a stored result where <condition>
When the owner sends POST /v1/system/update/download
Then the status is 422 and the body is plain text without technical detail
And no job starts, the fetcher is not called and the technical reason is logged
Examples:
| condition |
| no asset is named for the host platform |
| checksums.txt or checksums.txt.sig is missing from the assets |
| an asset URL is http, not https |
| an asset URL is on a host other than UPDATE_FEED_URL's host |
Scenario Outline: Verification failures end the job as failed, kind "verification"
Given a started job and <fault>
When the job reaches the check
Then GET reports state "failed", error "verification", progress null
And <data dir>/updates/<tag>/ is deleted
And the technical reason is logged and appears in no response body
Examples:
| fault |
| the binary was changed after signing (SHA-256 mismatch) |
| checksums.txt was changed after signing |
| the signature is from a different key |
| checksums.txt has no line for the asset |
| the staged binary prints "v2.2.9-beta" for --version |
| the staged binary cannot be run |
Scenario Outline: Download and disk failures end the job as failed, kind "download"
Given a started job and <fault>
Then GET reports state "failed", error "download"
And <data dir>/updates/<tag>/ is deleted and the technical reason is logged, not returned
Examples:
| fault |
| the connection drops after half the bytes (truncated download) |
| the bytes received differ from the size the release declares |
| a file exceeds its size cap |
| the download passes the overall timeout |
| the feed host answers 404 or 500 for an asset |
| the updates directory cannot be created or written (disk error) |
| the disk fills during the write |
| the database backup fails |
Scenario: The backup comes before ready, and the last 3 are kept
Given the backups directory holds 3 earlier backups
When a job completes
Then a new consistent copy exists (opened read-only it holds the same article rows as the live file)
And only the 3 newest backups remain, the oldest being deleted
And no job is "ready" until the new backup exists
And a failed backup leaves state "failed", error "download" and does not prune the earlier backups
Scenario: The production download policy
Given the production adapter with UPDATE_FEED_URL "https://git.example"
Then an http:// asset URL, an https URL on another host, and a redirect to another host or to http are refused
And an https URL on git.example is accepted
Scenario: A server --version flag
When the server binary is run with --version
Then it prints the build version and exits 0
And it does not read .env, open the database or start listening
And with no -ldflags version it prints "dev"
Scenario: Settings, owner, Update available with the helper detected (route /settings)
Given the owner and GET /v1/system/update resolves available true, can_apply true, state "idle", latest "v2.3.0-beta"
Then the block shows icon circle-arrow-up (info), "Version 2.3.0-beta is available" /
"You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart."
and the meta "Last checked ..."
And the notes button "What's new in 2.3.0-beta" is collapsed, and when open shows "Released 24 Sep 2026" and the list
And the one button is "Update" (download, primary) and the hint is "Takes about 2 minutes. Your site stays online."
And no server command, no "How to update" link, no Restart and no "Check for updates" is rendered
Scenario: Settings, owner, Downloading
When the owner presses "Update" and POST /v1/system/update/download answers 202
Then the block shows icon download (info), "Downloading version 2.3.0-beta…" /
"You can leave this page. The download continues and your site stays online."
And a progressbar labelled "Downloading version 2.3.0-beta" has aria-valuenow equal to progress.percent
and the text "42% · 11 of 26 MB"
And the button reads "Updating…" (loader-circle, secondary) and is disabled
And GET /v1/system/update is polled every second while state is downloading or verifying
Scenario: Settings, progress is announced at 25 percent steps only
Given the polled percent goes 3, 9, 24, 25, 26, 49, 50, 74, 75, 99, 100
Then the announcer text changes only at 25, 50, 75 and the step change, reading
"Downloading version 2.3.0-beta, 25 percent" and so on
And the visible text and aria-valuenow still update on every poll
And the live region does not announce the per-tick text
Scenario: Settings, owner, Verifying
Given the poll answers state "verifying"
Then the block shows icon shield-check (info), "Checking the download…" /
"Making sure the file is complete and safe to install. This takes a few seconds."
And the progressbar is indeterminate (no aria-valuenow, a 35% wide moving bar) labelled "Checking the download"
with the text "Almost there"
And the button reads "Updating…" and is disabled
And "Checking the download" is announced once, when the step changes
Scenario: Settings, owner, Ready to install
Given the poll answers state "ready"
Then polling stops
And the block shows icon circle-check (success), "Version 2.3.0-beta is ready to install" /
"The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds." /
"Nothing has changed yet."
And the notes button is shown collapsed
And the Restart button (power, primary) is rendered with disabled set and 40% opacity, and activating it does nothing
And the hint is "Not now? Leave this page. The update waits here, and the banner on Home reminds you."
And no dialog, overlay or POST /v1/system/update/restart exists
Scenario: Settings, Download failed
Given the poll answers state "failed" with error "download", or POST download answers 500 or never reaches the server
Then focus moves to the "Updates" heading (id="updates-h")
And the block shows icon circle-x (error), "The download didn't finish" /
"The connection dropped before the file was complete. Nothing was changed and your site is running as before."
And the one button is "Try again" (rotate-cw, primary), which sends POST /v1/system/update/download
Scenario: Settings, Verification failed
Given the poll answers state "failed" with error "verification", or POST download answers 422
Then the block shows icon triangle-alert (warning), "We didn't use this update" /
"The file didn't pass our safety check, so it was not installed. Nothing was changed. Try downloading it again. If this keeps happening, contact RenewCMS support."
And the one button is "Try again" (rotate-cw, primary), as the board frame draws it
And focus does not move to the heading
And no technical detail is on screen
Scenario: Settings, 409 already running
Given POST /v1/system/update/download answers 409 and the reload answers state "downloading" or "verifying"
Then an info toast "An update is already running" / "Another owner started it. Progress shows here." shows
And the card switches to that run's progress and polls
When the reload instead answers can_apply false
Then the card shows the "Not prepared" state and no toast
When the reload instead answers state "ready"
Then the card shows "Ready to install" and no toast
Scenario: Settings, 400 nothing to update
Given POST /v1/system/update/download answers 400
Then an info toast "Nothing to update" / "You already have the latest version." shows
And the card reloads GET /v1/system/update
Scenario: Settings, 403 role changed on Update
Given POST /v1/system/update/download 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 and renders the admin view
Scenario: Settings, 429 on Update
Given POST /v1/system/update/download 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" (clock, disabled) and counts down each second through useRetryAfter
And at zero it is re-enabled as "Try again" and pressing it sends POST /v1/system/update/download again
Scenario: Settings, reload keeps progress
Given a job is downloading and the owner leaves /settings
Then polling stops and the job continues on the server
When the owner opens /settings again, from this or another device
Then GET /v1/system/update is read on open and the card shows the job's current state and progress
And polling resumes while state is downloading or verifying
Scenario: Settings, a poll that fails keeps the card
Given a poll answers 500 or never reaches the server while downloading
Then the card keeps its last progress, no failure card shows, and polling continues each second
Scenario: Settings, admin view during a job
Given the session role is "admin" and state is "downloading"
Then the admin view of slice 1 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, progressbar or poll is rendered
Scenario: Home banner, ready to install (owner)
Given the owner is on /home and state is "ready"
Then a region labelled "Update available" shows above the content in info tone (as the board frame draws it)
with icon circle-check, title "Version 2.3.0-beta is ready to install",
text "Restart from Settings to finish. The site is unavailable for about 10 seconds.",
a "Go to Settings" button that navigates to /settings, and a Dismiss button
And it shows although the "available" banner for this version was dismissed earlier
When the owner dismisses it
Then it hides for this visit only and shows again on the next visit, and nothing is written to localStorage
And an admin sees no banner; with state "downloading" or "verifying" the "available" banner shows as before
Scenario: Pending states for slice 3
Then these remain pending in the visual gate, never passing: Restart confirm, Restart request failed, Restarting to install,
Restarting taking longer, Update done, Rolled back, Plain restart (confirm, restarting, done)
+113
View File
@@ -0,0 +1,113 @@
# QA: Updates part 2 (download, verify, stage)
## Automated Go (`task test`, package `test`, black-box, no live network, no real SMTP)
Files: `test/update_download_test.go` (domain, use case), `test/update_download_http_test.go` (HTTP),
`test/update_assets_test.go` (production download adapter against a local server). Fakes with unique names:
`fakeAssetFetcher` (serves bytes, can truncate, stall, fail, counts calls), `fakeHelperDetector`, `fakeJobStore`,
`fakeStagedRunner` (returns a configured --version output or error), `fakeDatabaseBackup`; reuse `fakeReleaseFeed`,
`fakeUpdateStore` and `mockUserRepo` from the slice 1 tests.
HTTP setup: `validator.InitValidator()`; `token := jwtauth.New("HS256", []byte("test-secret"), nil)`; a fresh `chi.NewRouter()` at
`/v1/system` mirroring `NewSystemRouter` (Verifier, Authenticator, RequireCurrentToken, RequireVerifiedUser; the download
route also RequireOwner and its limiter, 5 per minute); cookies as in `test/stale_token_invalidation_http_test.go`.
Job state, staged files and backups go in `t.TempDir()`. Wait for the job with a poll loop on GET (deadline 5 s), never a bare
`time.Sleep`. Use a throwaway ed25519 key generated in the test and sign `checksums.txt` with it; never read the committed
`release.pub`.
Local asset server: `httptest.NewServer` (plain HTTP) and `httptest.NewTLSServer` for the policy cases. The production adapter is
built through its constructor with the test's allowed host, allow-insecure true for the plain server, the TLS server's client for
the TLS cases, a small size cap and a short timeout. Never relax the policy by env var or build flag.
1. Happy path: owner POST gives 202 with state "downloading"; poll GET until "ready"; files in `updates/<tag>/`, one backup in
`backups/`; running binary unchanged; the fetcher was asked for exactly the three asset names.
2. Asset naming table over GOOS/GOARCH (including the `.exe` case). Inject the platform into the use case; never assert on the
test machine's own platform.
3. Progress: a fetcher paused at 11 of 26 MiB gives `{percent:42, downloaded_bytes, total_bytes}` on GET; `progress` is null in
verifying, ready and failed; `error` is null until a failure.
4. Shared state: GET as owner A, owner B and admin agree; admin `can_apply` false, owner true with the fake detector on.
5. Persistence: JSON job store round trip in a temp dir; "downloading" at startup reads failed/"download"; "ready" with the staged
binary deleted reads idle; a missing or corrupt file reads idle without error. The real helper detector reads
`<data dir>/update-helper.json`: absent file gives false, a present valid file gives true.
6. Guards: admin 403 with `apperr.OwnerRequiredErrorMessage+"\n"`; no cookie 401; stale token 401; unverified 403; none start a job.
7. Status codes: available false or nothing stored gives 400; detector off gives 409; state downloading, verifying, ready each give
409; after "failed" a new POST gives 202 with a new started_at. Order check: detector off and available false gives 409.
8. Concurrency: two simultaneous POSTs (goroutines) give one 202 and one 409; each asset fetched once (run with `-race`).
9. Rate limit: owner A sends 6 POSTs within a minute; the 6th is 429 with `apperr.TooManyRequestsErrorMessage+"\n"` and an integer
Retry-After >= 1; owner B is unaffected; the check limiter and a login limiter built in the test are untouched.
10. 422 pre-flight: each of the four conditions in the feature gives 422, the body is plain text with no URL, host or file name from
the fixture, no job record is created, fetcher count is 0, and the reason is captured from an injected logger.
11. Verification failures, one subtest per row, each ending with GET `state` "failed", `error` "verification", `updates/<tag>/`
removed, a body free of the fault text, and the fault text present in the injected logger: tampered binary, tampered
checksums.txt, signature from another key, no checksum line for the asset, wrong `--version` output, runner error.
12. Download failures, one subtest per row, each ending "failed", `error` "download", directory removed: truncated body, size
mismatch with the release, over the size cap, timeout (server stalls; adapter timeout shortened), 404/500, unwritable updates
directory (a file placed where the directory should be), disk full (a writer fake returning an error mid-write), backup failure
(earlier backups not pruned).
13. Backup: with the real SQLite backup adapter against a SQLite file created in `t.TempDir()` (pure-Go driver, `CGO_ENABLED=0`):
the copy opens and holds the same rows, four jobs leave only the 3 newest files, and a job is not "ready" before the copy exists.
14. Production policy: http URL, another host, redirect to another host and redirect to http are refused (no request reaches the
second server); the TLS server on the allowed host is accepted.
15. `--version`: build with `go build -tags nofrontend -ldflags "-X main.version=v9.9.9" ./cmd/server` into `t.TempDir()` and run
it with `--version` and an empty environment: stdout `v9.9.9`, exit 0, no `data/` created; without ldflags it prints `dev`.
16. Review items, not Go tests: `internal/domain/update` imports only the stdlib; use cases import no `net/http`, `os/exec`, `gorm`
or adapter; the handler imports no adapter; no secret value is in the repo; the helper detector reads only its status file.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi.request` for `GET /v1/system/update`, `POST /v1/system/update/check` and `POST /v1/system/update/download`. Fake timers
drive the 1 s poll and the countdown. No test freezes the system date and none asserts a relative date. Reuse the board's data:
running `v2.2.0-beta`, latest `v2.3.0-beta`, 26 MB total, 11 MB done (42 %), released 24 Sep 2026.
One test per state:
1. Update available, can_apply true: copy, notes collapsed, only "Update", hint; no command, no link, no Restart.
2. Update available, notes open (`aria-expanded`, "Released 24 Sep 2026").
3. Downloading: 202 then the card copy, progressbar label, `aria-valuenow` 42, "42% · 11 of 26 MB", "Updating…" disabled; GET called once per second.
4. Announcements: percents 3, 24, 25, 26, 49, 50, 75, 100; the announcer text changes only at 25, 50, 75 and the step change;
`aria-valuenow` changes on each tick.
5. Verifying: indeterminate bar (no `aria-valuenow`), "Almost there", announced once.
6. Ready: copy, meta, hint, Restart present with `disabled` and no request on click, notes button, polling stopped.
7. Download failed (state failed/download; a 500; a `NetworkError`): copy, focus on `#updates-h`, "Try again" posts download again.
8. Verification failed (state failed/verification; a 422): copy, "Try again", focus not moved, no technical text.
9. 409: reload downloading gives the info toast and progress; reload ready gives no toast and the Ready card; reload can_apply false
gives the Not prepared card.
10. 400: info toast "Nothing to update" and one GET.
11. 403: error toast and the admin view after reload.
12. 429: "Too many tries", "Try again in 0:42" counting down through `useRetryAfter`, re-enabled as "Try again" and it re-posts
download (not check).
13. Reload: unmount stops the poll; mount reads GET and resumes the poll for a running state; no poll for idle, ready or failed.
14. A failed poll keeps the last progress and keeps polling.
15. Admin with state downloading: admin copy, no button, no progressbar, no poll.
16. Home banner ready: info tone, copy, `role="region"` labelled "Update available", "Go to Settings" navigates, shows although the
available banner was dismissed, Dismiss hides until remount and writes nothing to localStorage; admin sees none.
17. Sidebar pill and mobile dot stay for ready, downloading and verifying (owner only, `available` true).
## Visual (`task visual:check -- settings` and `-- home`; desktop and mobile, light and dark; every capture within 10 %)
Add the setups to `web/visual/states.ts` with the board's sample data (v2.2.0-beta, v2.3.0-beta, 42 % / "11 of 26 MB"). Exact values
from `docs/design/source/AppFrame.dc.html` and the "Settings" and "Home" groups of the Screens board; layout as drawn.
- [ ] Settings: Update available, Update available (notes open), Downloading (42 %), Verifying, Ready to install,
Download failed, Verification failed. The frame draws an enabled Restart; the disabled one (40 % opacity) must still be
within budget. If it is not, stop and report; do not widen the budget or add an exception without the user's approval.
- [ ] Home: Update ready to install (owner), with the sidebar pill and the mobile dot as drawn.
- [ ] Settings: Update already running (409): the frame shows the card in progress; set up with the mocked 409 then the
downloading reload (the toast only if the frame draws it; check the markup).
- [ ] `skip: 'clock-sensitive'`: any state whose meta line is relative ("Last checked today at 09:41"), as in slice 1.
- [ ] Pending, never passing (slice 3): Restart confirm, Restart request failed, Restarting, Restarting taking longer,
Update done, Rolled back, Plain restart (confirm, restarting, done).
- [ ] Not covered by screenshots: keyboard and focus order, the focus ring on `#updates-h` after Download failed, the 25 %
announcements (screen reader), hover and motion of the indeterminate bar.
## Manual verification
- `can_apply` stays false on real builds until slice 3 writes `<data dir>/update-helper.json`: use the fake in tests, or place the
file by hand to see the Update button.
- Against a real signed release on the real feed host (needs `release.pub` replaced and `task release` signing): press Update, watch
progress, the Verifying step and the Ready card; check `<data dir>/updates/<tag>/` and one file in `<data dir>/backups/`.
- The real staged binary on the target OS runs `--version` and prints the tag.
- Flip one byte of a asset on a test mirror: the card shows "We didn't use this update" and the folder is gone.
- Kill the server mid-download and restart it: the card shows "The download didn't finish".
- Open the card in two browsers (two owners) and confirm the same progress; the second press shows the 409 toast.
- Embedded SPA build (no `-tags nofrontend`) serves /settings and /home with the new states.
- Confirm no secret or feed credential appears in logs, and that technical failure reasons are in the log only.