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
+5316 -68

No files matched your search

+50 -2
View File
@@ -14,9 +14,12 @@ import (
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"crypto/ed25519"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"time"
"github.com/glebarez/sqlite"
@@ -56,6 +59,7 @@ type UseCases struct {
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
}
type Repositories struct {
@@ -66,11 +70,21 @@ type Repositories struct {
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
UpdateJobs domainUpdate.JobStore
UpdateHelper domainUpdate.HelperDetector
AssetFetcher domainUpdate.AssetFetcher
SignatureVerifier domainUpdate.SignatureVerifier
StagedRunner domainUpdate.StagedBinaryRunner
DatabaseBackup domainUpdate.DatabaseBackup
}
const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json"
updateJobFileName = "update-job.json"
updateHelperFileName = "update-helper.json"
updatesDirName = "updates"
backupsDirName = "backups"
)
func SQLiteDSN(dbName string) string {
@@ -101,7 +115,16 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
return db
}
func releasePublicKey() ed25519.PublicKey {
key, err := update.EmbeddedPublicKey()
if err != nil {
panic("Unable to read the embedded release key: " + err.Error())
}
return key
}
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
dataDir := filepath.Dir(settings.DBFile)
return &Repositories{
ArticleRepo: persistence.NewArticleRepository(db),
UserRepo: persistence.NewUserRepository(db),
@@ -109,7 +132,13 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
InviteRepo: persistence.NewInviteRepository(db),
MailRepo: mailer.NewMailRepository(settings.Smtp),
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
UpdateStore: update.NewJSONStatusStore(filepath.Join(filepath.Dir(settings.DBFile), updateStatusFileName)),
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
UpdateHelper: update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)),
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
StagedRunner: update.NewExecStagedBinaryRunner(),
DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now),
}
}
@@ -146,11 +175,30 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, settings.Version),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, settings.Version),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings),
}
}
func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *useCases.StartUpdateDownloadUseCase {
return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{
Store: repos.UpdateStore,
Jobs: repos.UpdateJobs,
Helper: repos.UpdateHelper,
Fetcher: repos.AssetFetcher,
Verifier: repos.SignatureVerifier,
Runner: repos.StagedRunner,
Backup: repos.DatabaseBackup,
Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)),
Logger: log.Default(),
CurrentVersion: settings.Version,
Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH},
FeedURL: settings.UpdateFeedURL,
Now: time.Now,
})
}
func InitContainer(settings config.Settings) (*UseCases, func() error) {
db := getDb(settings.DBFile, settings.UploadDir)
repos := getRepositories(db, settings)
+70 -9
View File
@@ -1,39 +1,100 @@
package update
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"errors"
"net/http"
)
type Handler struct {
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
GetUserUseCase *useCases.GetUserUseCase
}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase) *Handler {
return &Handler{GetUpdateStatusUseCase: getStatus, CheckForUpdateUseCase: checkForUpdate}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, getUser *useCases.GetUserUseCase) *Handler {
return &Handler{
GetUpdateStatusUseCase: getStatus,
CheckForUpdateUseCase: checkForUpdate,
StartUpdateDownloadUseCase: startDownload,
GetUserUseCase: getUser,
}
}
func (h *Handler) GetStatus(w http.ResponseWriter, _ *http.Request) {
writeStatus(w, h.GetUpdateStatusUseCase.GetStatus())
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
}
func (h *Handler) Check(w http.ResponseWriter, _ *http.Request) {
status, err := h.CheckForUpdateUseCase.Check()
if err != nil {
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
if _, err := h.CheckForUpdateUseCase.Check(); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
writeStatus(w, status)
isOwner, ok := h.isOwner(w, r)
if !ok {
return
}
writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner))
}
func writeStatus(w http.ResponseWriter, status domain.UpdateStatus) {
func (h *Handler) Download(w http.ResponseWriter, _ *http.Request) {
status, err := h.StartUpdateDownloadUseCase.Start()
if err != nil {
writeDownloadError(w, err)
return
}
writeStatus(w, http.StatusAccepted, status)
}
func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) {
userId, ok := claims.UserId(r)
if !ok {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
currentUser, err := h.GetUserUseCase.GetUser(userId)
if err != nil {
if errors.Is(err, user.ErrUserNotFound) {
http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized)
return false, false
}
http.Error(w, "Internal server error", http.StatusInternalServerError)
return false, false
}
return currentUser.Role == user.RoleOwner, true
}
func writeDownloadError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, domain.ErrNotPrepared):
http.Error(w, apperr.UpdateNotPreparedErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrJobInProgress):
http.Error(w, apperr.UpdateAlreadyRunningErrorMessage, http.StatusConflict)
case errors.Is(err, domain.ErrNothingToUpdate):
http.Error(w, apperr.NothingToUpdateErrorMessage, http.StatusBadRequest)
case errors.Is(err, domain.ErrPreflightRefused):
http.Error(w, apperr.UpdateRefusedErrorMessage, http.StatusUnprocessableEntity)
default:
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
func writeStatus(w http.ResponseWriter, code int, status domain.UpdateStatus) {
body, err := json.Marshal(status)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(code)
_, _ = w.Write(body)
}
+4
View File
@@ -29,3 +29,7 @@ const MediaQueryErrorMessage = "The media query is invalid."
const MediaFilenameErrorMessage = "Enter a filename."
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it."
const UpdateNotPreparedErrorMessage = "This server cannot update itself yet."
const UpdateAlreadyRunningErrorMessage = "An update is already running or waiting to be installed."
const NothingToUpdateErrorMessage = "There is nothing to update."
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
+8 -1
View File
@@ -134,12 +134,14 @@ func NewSettingsRouter(container *UseCases) http.Handler {
const (
updateCheckMaxAttempts = 5
updateCheckWindow = time.Minute
updateDownloadMaxAttempts = 5
updateDownloadWindow = time.Minute
)
func NewSystemRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase)
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.GetUserUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
@@ -151,6 +153,11 @@ func NewSystemRouter(container *UseCases) http.Handler {
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check)
})
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow))
r.Post("/update/download", updateHandler.Download)
})
return r
}
+6
View File
@@ -50,7 +50,13 @@ var envDefaults = map[string]string{
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
}
const versionFlag = "--version"
func main() {
if len(os.Args) == 2 && os.Args[1] == versionFlag {
fmt.Println(version)
return
}
if err := run(); err != nil {
log.Fatal(err)
}
+4
View File
@@ -35,6 +35,10 @@ To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, wi
The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page only reports a newer version for now: the site does not update itself.
### Update downloads
Once the restart helper is installed (it writes `update-helper.json` next to `DB_FILE`), the owner's Update button downloads the new binary, `checksums.txt` and `checksums.txt.sig` from the release feed host over https only, checks the ed25519 signature and the SHA-256, runs the staged binary with `--version`, copies the database into `backups/` (the last 3 copies are kept) and stages everything under `updates/<tag>/`, all next to `DB_FILE`. The job record is `update-job.json`. Nothing running is replaced and the site is not restarted. `renewcms --version` prints the build version and exits.
## 2. Lay out the server
The following is a suggested layout for Linux:
+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.
+9
View File
@@ -19,17 +19,25 @@ type ReleaseNotes struct {
Items []string `json:"items"`
}
type Asset struct {
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
}
type Release struct {
Tag string
URL string
PublishedAt time.Time
Notes ReleaseNotes
Assets []Asset
}
type CheckResult struct {
Latest string `json:"latest"`
ReleaseURL string `json:"release_url"`
Notes ReleaseNotes `json:"notes"`
Assets []Asset `json:"assets"`
CheckedAt time.Time `json:"checked_at"`
}
@@ -58,6 +66,7 @@ func NewResult(release Release, checkedAt time.Time) CheckResult {
Latest: release.Tag,
ReleaseURL: release.URL,
Notes: release.Notes,
Assets: release.Assets,
CheckedAt: checkedAt.UTC(),
}
}
+177
View File
@@ -0,0 +1,177 @@
package update
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/url"
"os"
"strings"
"time"
)
const (
StateDownloading = "downloading"
StateVerifying = "verifying"
StateReady = "ready"
StateFailed = "failed"
ErrorKindDownload = "download"
ErrorKindVerification = "verification"
ChecksumsAssetName = "checksums.txt"
SignatureAssetName = "checksums.txt.sig"
windowsOS = "windows"
)
var (
ErrNotPrepared = errors.New("this server cannot apply updates")
ErrJobInProgress = errors.New("an update job is already running or ready")
ErrNothingToUpdate = errors.New("there is nothing to update")
ErrPreflightRefused = errors.New("the update cannot be downloaded")
)
type Platform struct {
OS string
Arch string
}
type Job struct {
Tag string `json:"tag"`
State string `json:"state"`
Error string `json:"error"`
StartedAt time.Time `json:"started_at"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
StagedBinary string `json:"staged_binary"`
}
type ProgressFunc func(done int64, total int64)
type AssetFetcher interface {
Fetch(asset Asset, destination string, report ProgressFunc) error
}
type SignatureVerifier interface {
Verify(checksumsPath string, signaturePath string) error
}
type StagedBinaryRunner interface {
Version(binaryPath string) (string, error)
}
type DatabaseBackup interface {
Backup() (string, error)
}
type StagingArea interface {
Prepare(tag string) error
PathFor(tag string, name string) string
Size(path string) (int64, error)
ReadChecksums(path string) (string, error)
MakeExecutable(path string) error
Remove(tag string) error
}
type HelperDetector interface {
Installed() bool
}
type JobStore interface {
Load() (Job, bool)
Save(job Job) error
}
type Logger interface {
Printf(format string, args ...any)
}
func (j Job) IsRunning() bool {
return j.State == StateDownloading || j.State == StateVerifying
}
func (j Job) Occupies() bool {
return j.IsRunning() || j.State == StateReady
}
func (j Job) Progress() *Progress {
if j.State != StateDownloading || j.TotalBytes <= 0 {
return nil
}
return &Progress{
Percent: int(j.DownloadedBytes * 100 / j.TotalBytes),
DownloadedBytes: j.DownloadedBytes,
TotalBytes: j.TotalBytes,
}
}
func (s UpdateStatus) WithJob(job Job) UpdateStatus {
s.State = job.State
s.Progress = job.Progress()
s.Error = nil
if job.Error != "" {
kind := job.Error
s.Error = &kind
}
startedAt := job.StartedAt
s.StartedAt = &startedAt
return s
}
func AssetName(tag string, platform Platform) string {
name := "renewcms_" + tag + "_" + platform.OS + "_" + platform.Arch
if platform.OS == windowsOS {
name += ".exe"
}
return name
}
func FindAsset(assets []Asset, name string) (Asset, bool) {
for _, asset := range assets {
if asset.Name == name {
return asset, true
}
}
return Asset{}, false
}
func HostOf(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
return parsed.Host
}
func IsAllowedAssetURL(rawURL string, allowedHost string) bool {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Scheme != "https" {
return false
}
return allowedHost != "" && parsed.Host == allowedHost
}
func ChecksumFor(checksums string, assetName string) (string, bool) {
for _, line := range strings.Split(checksums, "\n") {
fields := strings.Fields(line)
if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == assetName {
return strings.ToLower(fields[0]), true
}
}
return "", false
}
func FileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
@@ -0,0 +1,44 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"crypto/ed25519"
"encoding/base64"
"errors"
"os"
"strings"
)
var (
errSignatureEncoding = errors.New("the signature is not a base64 encoded ed25519 signature")
errSignatureMismatch = errors.New("the signature does not match the checksums file")
)
type Ed25519SignatureVerifier struct {
key ed25519.PublicKey
}
var _ domain.SignatureVerifier = &Ed25519SignatureVerifier{}
func NewEd25519SignatureVerifier(key ed25519.PublicKey) *Ed25519SignatureVerifier {
return &Ed25519SignatureVerifier{key: key}
}
func (v *Ed25519SignatureVerifier) Verify(checksumsPath string, signaturePath string) error {
content, err := os.ReadFile(checksumsPath)
if err != nil {
return err
}
rawSignature, err := os.ReadFile(signaturePath)
if err != nil {
return err
}
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(rawSignature)))
if err != nil || len(signature) != ed25519.SignatureSize {
return errSignatureEncoding
}
if len(v.key) != ed25519.PublicKeySize || !ed25519.Verify(v.key, content, signature) {
return errSignatureMismatch
}
return nil
}
@@ -0,0 +1,29 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"context"
"os/exec"
"strings"
"time"
)
const stagedBinaryRunTimeout = 15 * time.Second
type ExecStagedBinaryRunner struct{}
var _ domain.StagedBinaryRunner = &ExecStagedBinaryRunner{}
func NewExecStagedBinaryRunner() *ExecStagedBinaryRunner {
return &ExecStagedBinaryRunner{}
}
func (r *ExecStagedBinaryRunner) Version(binaryPath string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), stagedBinaryRunTimeout)
defer cancel()
output, err := exec.CommandContext(ctx, binaryPath, "--version").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
@@ -0,0 +1,26 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"encoding/json"
"os"
)
type FileHelperDetector struct {
statusPath string
}
var _ domain.HelperDetector = &FileHelperDetector{}
func NewFileHelperDetector(statusPath string) *FileHelperDetector {
return &FileHelperDetector{statusPath: statusPath}
}
func (d *FileHelperDetector) Installed() bool {
content, err := os.ReadFile(d.statusPath)
if err != nil {
return false
}
var status map[string]any
return json.Unmarshal(content, &status) == nil && status != nil
}
@@ -29,6 +29,13 @@ type giteaRelease struct {
Body string `json:"body"`
Draft bool `json:"draft"`
PublishedAt time.Time `json:"published_at"`
Assets []giteaAsset `json:"assets"`
}
type giteaAsset struct {
Name string `json:"name"`
Size int64 `json:"size"`
DownloadURL string `json:"browser_download_url"`
}
func NewGiteaReleaseFeed(baseURL string) *GiteaReleaseFeed {
@@ -73,7 +80,16 @@ func toReleases(raw []giteaRelease) []domain.Release {
URL: item.HTMLURL,
PublishedAt: item.PublishedAt,
Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body),
Assets: toAssets(item.Assets),
})
}
return releases
}
func toAssets(raw []giteaAsset) []domain.Asset {
assets := make([]domain.Asset, 0, len(raw))
for _, item := range raw {
assets = append(assets, domain.Asset{Name: item.Name, URL: item.DownloadURL, Size: item.Size})
}
return assets
}
@@ -0,0 +1,185 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const (
maxBinaryBytes int64 = 200 << 20
maxChecksumsBytes int64 = 64 << 10
maxSignatureBytes int64 = 1 << 10
assetDownloadTimeout = 10 * time.Minute
maxAssetRedirects = 5
assetFilePermission = 0o644
signatureSuffix = ".sig"
)
var (
errAssetURLRefused = errors.New("the asset url is not https on the release feed host")
errAssetStatus = errors.New("the asset server answered with an error status")
errAssetTooLarge = errors.New("the asset is larger than its size cap")
errTooManyRedirects = errors.New("the asset url redirected too many times")
)
type FileOpener func(path string) (io.WriteCloser, error)
type HTTPAssetFetcher struct {
allowedHost string
allowInsecure bool
sizeCap int64
timeout time.Duration
transport http.RoundTripper
openFile FileOpener
}
var _ domain.AssetFetcher = &HTTPAssetFetcher{}
type FetcherOption func(*HTTPAssetFetcher)
func WithInsecureScheme() FetcherOption {
return func(f *HTTPAssetFetcher) { f.allowInsecure = true }
}
func WithSizeCap(bytes int64) FetcherOption {
return func(f *HTTPAssetFetcher) { f.sizeCap = bytes }
}
func WithTimeout(timeout time.Duration) FetcherOption {
return func(f *HTTPAssetFetcher) { f.timeout = timeout }
}
func WithTransport(transport http.RoundTripper) FetcherOption {
return func(f *HTTPAssetFetcher) { f.transport = transport }
}
func WithFileOpener(opener FileOpener) FetcherOption {
return func(f *HTTPAssetFetcher) { f.openFile = opener }
}
func NewHTTPAssetFetcher(feedURL string, options ...FetcherOption) *HTTPAssetFetcher {
fetcher := &HTTPAssetFetcher{
allowedHost: domain.HostOf(feedURL),
timeout: assetDownloadTimeout,
openFile: createAssetFile,
}
for _, option := range options {
option(fetcher)
}
return fetcher
}
func (f *HTTPAssetFetcher) Fetch(asset domain.Asset, destination string, report domain.ProgressFunc) error {
if !f.isAllowed(asset.URL) {
return errAssetURLRefused
}
ctx, cancel := context.WithTimeout(context.Background(), f.timeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
if err != nil {
return err
}
response, err := f.client().Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %d", errAssetStatus, response.StatusCode)
}
limit := f.capFor(asset.Name)
if response.ContentLength > limit {
return errAssetTooLarge
}
if err := f.writeBody(response.Body, destination, limit, asset.Size, report); err != nil {
_ = os.Remove(destination)
return err
}
return nil
}
func (f *HTTPAssetFetcher) writeBody(body io.Reader, destination string, limit int64, declaredSize int64, report domain.ProgressFunc) error {
file, err := f.openFile(destination)
if err != nil {
return err
}
counter := &progressWriter{destination: file, total: declaredSize, report: report}
written, copyErr := io.Copy(counter, io.LimitReader(body, limit+1))
closeErr := file.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
if written > limit {
return errAssetTooLarge
}
return nil
}
func (f *HTTPAssetFetcher) isAllowed(rawURL string) bool {
parsed, err := url.Parse(rawURL)
if err != nil || f.allowedHost == "" || parsed.Host != f.allowedHost {
return false
}
return parsed.Scheme == "https" || (f.allowInsecure && parsed.Scheme == "http")
}
func (f *HTTPAssetFetcher) client() *http.Client {
return &http.Client{
Transport: f.transport,
CheckRedirect: func(next *http.Request, via []*http.Request) error {
if len(via) >= maxAssetRedirects {
return errTooManyRedirects
}
if !f.isAllowed(next.URL.String()) {
return errAssetURLRefused
}
return nil
},
}
}
func (f *HTTPAssetFetcher) capFor(name string) int64 {
if f.sizeCap > 0 {
return f.sizeCap
}
if name == domain.ChecksumsAssetName {
return maxChecksumsBytes
}
if strings.HasSuffix(name, signatureSuffix) {
return maxSignatureBytes
}
return maxBinaryBytes
}
type progressWriter struct {
destination io.Writer
total int64
done int64
report domain.ProgressFunc
}
func (w *progressWriter) Write(chunk []byte) (int, error) {
written, err := w.destination.Write(chunk)
w.done += int64(written)
w.report(w.done, w.total)
return written, err
}
func createAssetFile(path string) (io.WriteCloser, error) {
if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil {
return nil, err
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, assetFilePermission)
}
@@ -0,0 +1,90 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"encoding/json"
"os"
"path/filepath"
"sync"
)
type JSONJobStore struct {
path string
mu sync.Mutex
}
var _ domain.JobStore = &JSONJobStore{}
func NewJSONJobStore(path string) *JSONJobStore {
store := &JSONJobStore{path: path}
store.failInterruptedJob()
return store
}
func (s *JSONJobStore) failInterruptedJob() {
job, found := s.read()
if !found || !job.IsRunning() {
return
}
job.State = domain.StateFailed
job.Error = domain.ErrorKindDownload
job.DownloadedBytes = 0
job.TotalBytes = 0
_ = s.write(job)
}
func (s *JSONJobStore) Load() (domain.Job, bool) {
s.mu.Lock()
defer s.mu.Unlock()
job, found := s.read()
if !found {
return domain.Job{}, false
}
if job.State == domain.StateReady && !fileExists(job.StagedBinary) {
return domain.Job{}, false
}
return job, true
}
func (s *JSONJobStore) Save(job domain.Job) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.write(job)
}
func (s *JSONJobStore) read() (domain.Job, bool) {
content, err := os.ReadFile(s.path)
if err != nil {
return domain.Job{}, false
}
var job domain.Job
if err := json.Unmarshal(content, &job); err != nil || job.State == "" {
return domain.Job{}, false
}
return job, true
}
func (s *JSONJobStore) write(job domain.Job) error {
content, err := json.Marshal(job)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(s.path), os.ModePerm); err != nil {
return err
}
temporary := s.path + ".tmp"
if err := os.WriteFile(temporary, content, statusFilePermissions); err != nil {
return err
}
return os.Rename(temporary, s.path)
}
func fileExists(path string) bool {
if path == "" {
return false
}
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
@@ -0,0 +1,58 @@
package update
import (
"RenewCMS/internal/domain/update"
"os"
"path/filepath"
)
const (
stagedDirectoryPermissions = 0o755
stagedBinaryPermissions = 0o755
)
type OSStagingArea struct {
root string
}
var _ update.StagingArea = &OSStagingArea{}
func NewOSStagingArea(root string) *OSStagingArea {
return &OSStagingArea{root: root}
}
func (a *OSStagingArea) Prepare(tag string) error {
directory := filepath.Join(a.root, tag)
if err := os.RemoveAll(directory); err != nil {
return err
}
return os.MkdirAll(directory, stagedDirectoryPermissions)
}
func (a *OSStagingArea) PathFor(tag string, name string) string {
return filepath.Join(a.root, tag, name)
}
func (a *OSStagingArea) Size(path string) (int64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
func (a *OSStagingArea) ReadChecksums(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return string(content), nil
}
func (a *OSStagingArea) MakeExecutable(path string) error {
return os.Chmod(path, stagedBinaryPermissions)
}
func (a *OSStagingArea) Remove(tag string) error {
return os.RemoveAll(filepath.Join(a.root, tag))
}
@@ -0,0 +1,87 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
var errBackupExists = errors.New("a backup with this name already exists")
const (
backupsToKeep = 3
backupFileExtension = ".db"
backupFilePrefix = "renewcms-"
backupTimeLayout = "20060102T150405.000000000Z"
)
type SQLiteDatabaseBackup struct {
db *gorm.DB
directory string
now func() time.Time
}
var _ domain.DatabaseBackup = &SQLiteDatabaseBackup{}
func NewSQLiteDatabaseBackup(db *gorm.DB, directory string, now func() time.Time) *SQLiteDatabaseBackup {
return &SQLiteDatabaseBackup{db: db, directory: directory, now: now}
}
func (b *SQLiteDatabaseBackup) Backup() (string, error) {
if err := os.MkdirAll(b.directory, os.ModePerm); err != nil {
return "", err
}
target := filepath.Join(b.directory, backupFilePrefix+b.now().UTC().Format(backupTimeLayout)+backupFileExtension)
if _, err := os.Stat(target); err == nil {
return "", errBackupExists
}
if err := b.db.Exec("VACUUM INTO ?", target).Error; err != nil {
_ = os.Remove(target)
return "", err
}
if err := b.prune(); err != nil {
return "", err
}
return target, nil
}
type backupFile struct {
path string
modified time.Time
}
func (b *SQLiteDatabaseBackup) prune() error {
entries, err := os.ReadDir(b.directory)
if err != nil {
return err
}
files := make([]backupFile, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), backupFileExtension) {
continue
}
info, err := entry.Info()
if err != nil {
return err
}
files = append(files, backupFile{path: filepath.Join(b.directory, entry.Name()), modified: info.ModTime()})
}
sort.Slice(files, func(i, j int) bool {
if files[i].modified.Equal(files[j].modified) {
return files[i].path > files[j].path
}
return files[i].modified.After(files[j].modified)
})
for _, stale := range files[min(backupsToKeep, len(files)):] {
if err := os.Remove(stale.path); err != nil {
return err
}
}
return nil
}
@@ -4,17 +4,29 @@ import "RenewCMS/internal/domain/update"
type GetUpdateStatusUseCase struct {
store update.StatusStore
jobs update.JobStore
helper update.HelperDetector
currentVersion string
}
func NewGetUpdateStatusUseCase(store update.StatusStore, currentVersion string) *GetUpdateStatusUseCase {
return &GetUpdateStatusUseCase{store: store, currentVersion: currentVersion}
func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, currentVersion string) *GetUpdateStatusUseCase {
return &GetUpdateStatusUseCase{store: store, jobs: jobs, helper: helper, currentVersion: currentVersion}
}
func (g *GetUpdateStatusUseCase) GetStatus() update.UpdateStatus {
result, found := g.store.Load()
if !found {
return update.NewStatus(g.currentVersion, nil)
func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus {
return buildUpdateStatus(g.store, g.jobs, g.currentVersion, isOwner && g.helper.Installed())
}
func buildUpdateStatus(store update.StatusStore, jobs update.JobStore, currentVersion string, canApply bool) update.UpdateStatus {
var status update.UpdateStatus
if result, found := store.Load(); found {
status = update.NewStatus(currentVersion, &result)
} else {
status = update.NewStatus(currentVersion, nil)
}
return update.NewStatus(g.currentVersion, &result)
if job, found := jobs.Load(); found {
status = status.WithJob(job)
}
status.CanApply = canApply
return status
}
@@ -0,0 +1,263 @@
package useCases
import (
"RenewCMS/internal/domain/update"
"errors"
"fmt"
"sync"
"time"
)
type StartUpdateDownloadUseCase struct {
store update.StatusStore
jobs update.JobStore
helper update.HelperDetector
fetcher update.AssetFetcher
verifier update.SignatureVerifier
runner update.StagedBinaryRunner
backup update.DatabaseBackup
staging update.StagingArea
logger update.Logger
currentVersion string
platform update.Platform
feedHost string
now func() time.Time
mu sync.Mutex
}
type StartUpdateDownloadDependencies struct {
Store update.StatusStore
Jobs update.JobStore
Helper update.HelperDetector
Fetcher update.AssetFetcher
Verifier update.SignatureVerifier
Runner update.StagedBinaryRunner
Backup update.DatabaseBackup
Staging update.StagingArea
Logger update.Logger
CurrentVersion string
Platform update.Platform
FeedURL string
Now func() time.Time
}
type jobFailure struct {
kind string
reason error
}
func (f jobFailure) Error() string {
return f.reason.Error()
}
func NewStartUpdateDownloadUseCase(dependencies StartUpdateDownloadDependencies) *StartUpdateDownloadUseCase {
return &StartUpdateDownloadUseCase{
store: dependencies.Store,
jobs: dependencies.Jobs,
helper: dependencies.Helper,
fetcher: dependencies.Fetcher,
verifier: dependencies.Verifier,
runner: dependencies.Runner,
backup: dependencies.Backup,
staging: dependencies.Staging,
logger: dependencies.Logger,
currentVersion: dependencies.CurrentVersion,
platform: dependencies.Platform,
feedHost: update.HostOf(dependencies.FeedURL),
now: dependencies.Now,
}
}
func (s *StartUpdateDownloadUseCase) Start() (update.UpdateStatus, error) {
if !s.helper.Installed() {
return update.UpdateStatus{}, update.ErrNotPrepared
}
s.mu.Lock()
defer s.mu.Unlock()
if job, found := s.jobs.Load(); found && job.Occupies() {
return update.UpdateStatus{}, update.ErrJobInProgress
}
result, found := s.store.Load()
if !found || !update.IsNewer(s.currentVersion, result.Latest) {
return update.UpdateStatus{}, update.ErrNothingToUpdate
}
binary, checksums, signature, err := s.preflight(result)
if err != nil {
s.logger.Printf("update download refused: %v", err)
return update.UpdateStatus{}, update.ErrPreflightRefused
}
job := update.Job{
Tag: result.Latest,
State: update.StateDownloading,
StartedAt: s.now().UTC(),
TotalBytes: binary.Size,
}
if err := s.jobs.Save(job); err != nil {
return update.UpdateStatus{}, err
}
status := buildUpdateStatus(s.store, s.jobs, s.currentVersion, true)
go s.run(job, binary, checksums, signature)
return status, nil
}
func (s *StartUpdateDownloadUseCase) preflight(result update.CheckResult) (binary, checksums, signature update.Asset, err error) {
names := []string{update.AssetName(result.Latest, s.platform), update.ChecksumsAssetName, update.SignatureAssetName}
found := make([]update.Asset, 0, len(names))
for _, name := range names {
asset, ok := update.FindAsset(result.Assets, name)
if !ok {
return binary, checksums, signature, fmt.Errorf("asset %q is not listed for %s", name, result.Latest)
}
if !update.IsAllowedAssetURL(asset.URL, s.feedHost) {
return binary, checksums, signature, fmt.Errorf("asset %q has a url outside the feed host or without https", name)
}
found = append(found, asset)
}
return found[0], found[1], found[2], nil
}
func (s *StartUpdateDownloadUseCase) run(job update.Job, binary, checksums, signature update.Asset) {
defer func() {
if recovered := recover(); recovered != nil {
s.fail(job, downloadFailure(fmt.Errorf("panic: %v", recovered)))
}
}()
if err := s.execute(&job, binary, checksums, signature); err != nil {
s.fail(job, err)
}
}
func (s *StartUpdateDownloadUseCase) execute(job *update.Job, binary, checksums, signature update.Asset) error {
binaryPath := s.staging.PathFor(job.Tag, binary.Name)
checksumsPath := s.staging.PathFor(job.Tag, checksums.Name)
signaturePath := s.staging.PathFor(job.Tag, signature.Name)
if err := s.staging.Prepare(job.Tag); err != nil {
return downloadFailure(err)
}
if err := s.fetchSmall(checksums, checksumsPath); err != nil {
return downloadFailure(err)
}
if err := s.fetchSmall(signature, signaturePath); err != nil {
return downloadFailure(err)
}
if err := s.fetchBinary(job, binary, binaryPath); err != nil {
return downloadFailure(err)
}
job.State = update.StateVerifying
if err := s.jobs.Save(*job); err != nil {
return downloadFailure(err)
}
if err := s.verify(job.Tag, binary.Name, binaryPath, checksumsPath, signaturePath); err != nil {
return jobFailure{kind: update.ErrorKindVerification, reason: err}
}
if _, err := s.backup.Backup(); err != nil {
return downloadFailure(err)
}
job.State = update.StateReady
job.StagedBinary = binaryPath
job.DownloadedBytes = 0
job.TotalBytes = 0
return s.jobs.Save(*job)
}
func (s *StartUpdateDownloadUseCase) fetchSmall(asset update.Asset, destination string) error {
if err := s.fetcher.Fetch(asset, destination, ignoreProgress); err != nil {
return err
}
return s.assertDeclaredSize(asset, destination)
}
func (s *StartUpdateDownloadUseCase) fetchBinary(job *update.Job, asset update.Asset, destination string) error {
lastPercent := -1
report := func(done int64, _ int64) {
current := *job
current.DownloadedBytes = done
percent := 0
if progress := current.Progress(); progress != nil {
percent = progress.Percent
}
if percent == lastPercent && done < asset.Size {
return
}
lastPercent = percent
_ = s.jobs.Save(current)
}
if err := s.fetcher.Fetch(asset, destination, report); err != nil {
return err
}
return s.assertDeclaredSize(asset, destination)
}
func ignoreProgress(int64, int64) {}
func (s *StartUpdateDownloadUseCase) assertDeclaredSize(asset update.Asset, path string) error {
size, err := s.staging.Size(path)
if err != nil {
return err
}
if asset.Size > 0 && size != asset.Size {
return fmt.Errorf("received %d bytes for %q, the release declares %d", size, asset.Name, asset.Size)
}
return nil
}
func (s *StartUpdateDownloadUseCase) verify(tag, assetName, binaryPath, checksumsPath, signaturePath string) error {
if err := s.verifier.Verify(checksumsPath, signaturePath); err != nil {
return fmt.Errorf("signature check: %w", err)
}
content, err := s.staging.ReadChecksums(checksumsPath)
if err != nil {
return err
}
expected, found := update.ChecksumFor(content, assetName)
if !found {
return fmt.Errorf("checksums.txt has no line for %q", assetName)
}
actual, err := update.FileSHA256(binaryPath)
if err != nil {
return err
}
if actual != expected {
return fmt.Errorf("sha-256 mismatch for %q", assetName)
}
if err := s.staging.MakeExecutable(binaryPath); err != nil {
return err
}
output, err := s.runner.Version(binaryPath)
if err != nil {
return fmt.Errorf("running the staged binary: %w", err)
}
if output != tag {
return fmt.Errorf("the staged binary reports version %q, expected %q", output, tag)
}
return nil
}
func downloadFailure(err error) error {
return jobFailure{kind: update.ErrorKindDownload, reason: err}
}
func (s *StartUpdateDownloadUseCase) fail(job update.Job, err error) {
kind := update.ErrorKindDownload
var failure jobFailure
if errors.As(err, &failure) {
kind = failure.kind
}
s.logger.Printf("update to %s failed (%s): %v", job.Tag, kind, err)
_ = s.staging.Remove(job.Tag)
job.State = update.StateFailed
job.Error = kind
job.DownloadedBytes = 0
job.TotalBytes = 0
job.StagedBinary = ""
if saveErr := s.jobs.Save(job); saveErr != nil {
s.logger.Printf("update job record could not be saved: %v", saveErr)
}
}
+86
View File
@@ -0,0 +1,86 @@
package test
import (
infraUpdate "RenewCMS/internal/infrastructure/update"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func buildServerBinary(t *testing.T, ldflags string) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("the go tool is not on PATH")
}
name := "server"
if runtime.GOOS == "windows" {
name += ".exe"
}
output := filepath.Join(t.TempDir(), name)
arguments := []string{"build", "-tags", "nofrontend"}
if ldflags != "" {
arguments = append(arguments, "-ldflags", ldflags)
}
arguments = append(arguments, "-o", output, "RenewCMS/cmd/server")
build := exec.Command("go", arguments...)
build.Env = append(os.Environ(), "CGO_ENABLED=0")
if combined, err := build.CombinedOutput(); err != nil {
t.Fatalf("build failed: %v\n%s", err, combined)
}
return output
}
func emptyEnvironment() []string {
if runtime.GOOS == "windows" {
return []string{"SystemRoot=" + os.Getenv("SystemRoot")}
}
return []string{}
}
func TestServerVersionFlag(t *testing.T) {
t.Run("it prints the build version and exits 0 without reading .env or the database", func(t *testing.T) {
binary := buildServerBinary(t, "-X main.version=v9.9.9")
workDir := t.TempDir()
command := exec.Command(binary, "--version")
command.Dir = workDir
command.Env = emptyEnvironment()
output, err := command.Output()
if err != nil || strings.TrimSpace(string(output)) != "v9.9.9" {
t.Fatalf("output = %q, err = %v", output, err)
}
if entries, _ := os.ReadDir(workDir); len(entries) != 0 {
t.Errorf("the working directory must stay empty, got %d entries", len(entries))
}
})
t.Run("with no ldflags version it prints dev", func(t *testing.T) {
binary := buildServerBinary(t, "")
command := exec.Command(binary, "--version")
command.Dir = t.TempDir()
command.Env = emptyEnvironment()
output, err := command.Output()
if err != nil || strings.TrimSpace(string(output)) != "dev" {
t.Errorf("output = %q, err = %v", output, err)
}
})
t.Run("the staged binary runner returns what the binary prints", func(t *testing.T) {
binary := buildServerBinary(t, "-X main.version=v9.9.9")
version, err := infraUpdate.NewExecStagedBinaryRunner().Version(binary)
if err != nil || version != "v9.9.9" {
t.Errorf("version = %q, err = %v", version, err)
}
if _, err := infraUpdate.NewExecStagedBinaryRunner().Version(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("a binary that cannot be run must be an error")
}
})
}
+344
View File
@@ -0,0 +1,344 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
var errAssetDisk = errors.New("write failed: no space left on device")
type failingWriteCloser struct {
file *os.File
allowed int
}
func (f *failingWriteCloser) Write(chunk []byte) (int, error) {
if f.allowed <= 0 {
return 0, errAssetDisk
}
if len(chunk) > f.allowed {
written, _ := f.file.Write(chunk[:f.allowed])
f.allowed = 0
return written, errAssetDisk
}
f.allowed -= len(chunk)
return f.file.Write(chunk)
}
func (f *failingWriteCloser) Close() error {
return f.file.Close()
}
func assetOf(server *httptest.Server, name string, size int64) update.Asset {
return update.Asset{Name: name, URL: server.URL + "/" + name, Size: size}
}
func fetchTarget(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "staged", "asset")
}
func noProgress(int64, int64) {}
func TestHTTPAssetFetcherDownloads(t *testing.T) {
payload := bytes.Repeat([]byte("renewcms"), 512)
t.Run("writes the body and reports bytes done and total", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(payload) }))
defer server.Close()
fetcher := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme())
target := fetchTarget(t)
var lastDone, lastTotal int64
err := fetcher.Fetch(assetOf(server, "binary", int64(len(payload))), target, func(done, total int64) { lastDone, lastTotal = done, total })
written, readErr := os.ReadFile(target)
if err != nil || readErr != nil || !bytes.Equal(written, payload) {
t.Fatalf("err = %v, read = %v", err, readErr)
}
if lastDone != int64(len(payload)) || lastTotal != int64(len(payload)) {
t.Errorf("progress = %d of %d", lastDone, lastTotal)
}
})
t.Run("a truncated body is an error and leaves no file", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "4096")
_, _ = w.Write(payload[:2048])
w.(http.Flusher).Flush()
panic(http.ErrAbortHandler)
}))
defer server.Close()
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 4096), target, noProgress)
if err == nil {
t.Error("a truncated download must fail")
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Error("the partial file must be removed")
}
})
t.Run("a file over its size cap is refused", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Transfer-Encoding", "chunked")
_, _ = w.Write(payload)
}))
defer server.Close()
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), target, noProgress)
if err == nil {
t.Error("the size cap must stop the download")
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Error("the oversized file must be removed")
}
})
t.Run("the production caps are 64 KiB for checksums and 1 KiB for the signature", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(bytes.Repeat([]byte("a"), 2048))
}))
defer server.Close()
fetcher := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme())
if err := fetcher.Fetch(assetOf(server, "checksums.txt.sig", 2048), fetchTarget(t), noProgress); err == nil {
t.Error("a 2 KiB signature must exceed the 1 KiB cap")
}
if err := fetcher.Fetch(assetOf(server, "checksums.txt", 2048), fetchTarget(t), noProgress); err != nil {
t.Errorf("2 KiB of checksums is within the cap: %v", err)
}
})
t.Run("a stalled server hits the overall timeout", func(t *testing.T) {
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "4096")
_, _ = w.Write(payload[:10])
w.(http.Flusher).Flush()
select {
case <-release:
case <-r.Context().Done():
}
}))
defer server.Close()
defer close(release)
started := time.Now()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithTimeout(150*time.Millisecond)).Fetch(assetOf(server, "binary", 4096), fetchTarget(t), noProgress)
if err == nil || time.Since(started) > 3*time.Second {
t.Errorf("err = %v after %v", err, time.Since(started))
}
})
t.Run("a 404 or 500 is an error", func(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) }))
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 1), target, noProgress)
server.Close()
if err == nil {
t.Errorf("status %d must fail", status)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Errorf("status %d left a file", status)
}
}
})
t.Run("the disk filling during the write is an error and leaves no file", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(payload) }))
defer server.Close()
opener := func(path string) (io.WriteCloser, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
file, err := os.Create(path)
return &failingWriteCloser{file: file, allowed: 100}, err
}
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithFileOpener(opener)).Fetch(assetOf(server, "binary", int64(len(payload))), target, noProgress)
if !errors.Is(err, errAssetDisk) {
t.Errorf("err = %v", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Error("the partial file must be removed")
}
})
}
func TestHTTPAssetFetcherProductionPolicy(t *testing.T) {
payload := []byte("signed release asset")
t.Run("an http URL is refused", func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = w.Write(payload)
}))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL).Fetch(assetOf(server, "binary", int64(len(payload))), fetchTarget(t), noProgress)
if err == nil || requests.Load() != 0 {
t.Errorf("err = %v, requests = %d", err, requests.Load())
}
})
t.Run("an https URL on another host is refused", func(t *testing.T) {
var requests atomic.Int32
other := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) }))
defer other.Close()
allowed := httptest.NewTLSServer(http.NotFoundHandler())
defer allowed.Close()
err := infraUpdate.NewHTTPAssetFetcher(allowed.URL, infraUpdate.WithTransport(other.Client().Transport)).Fetch(assetOf(other, "binary", 1), fetchTarget(t), noProgress)
if err == nil || requests.Load() != 0 {
t.Errorf("err = %v, requests = %d", err, requests.Load())
}
})
t.Run("a redirect to another host is refused", func(t *testing.T) {
var reached atomic.Int32
second := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached.Add(1)
_, _ = w.Write(payload)
}))
defer second.Close()
first := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, second.URL+"/binary", http.StatusFound)
}))
defer first.Close()
err := infraUpdate.NewHTTPAssetFetcher(first.URL, infraUpdate.WithTransport(first.Client().Transport)).Fetch(assetOf(first, "binary", 1), fetchTarget(t), noProgress)
if err == nil || reached.Load() != 0 {
t.Errorf("err = %v, second server reached %d times", err, reached.Load())
}
})
t.Run("a redirect to http is refused", func(t *testing.T) {
var reached atomic.Int32
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached.Add(1)
_, _ = w.Write(payload)
}))
defer plain.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, strings.Replace(r.URL.Path, "/", plain.URL+"/", 1), http.StatusFound)
}))
defer secure.Close()
err := infraUpdate.NewHTTPAssetFetcher(secure.URL, infraUpdate.WithTransport(secure.Client().Transport)).Fetch(assetOf(secure, "binary", 1), fetchTarget(t), noProgress)
if err == nil || reached.Load() != 0 {
t.Errorf("err = %v, plain server reached %d times", err, reached.Load())
}
})
t.Run("an https URL on the allowed host is accepted, also after a same host redirect", func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/binary" {
http.Redirect(w, r, "/final", http.StatusFound)
return
}
_, _ = w.Write(payload)
}))
defer server.Close()
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithTransport(server.Client().Transport)).Fetch(assetOf(server, "binary", int64(len(payload))), target, noProgress)
written, _ := os.ReadFile(target)
if err != nil || !bytes.Equal(written, payload) {
t.Errorf("err = %v, body = %q", err, written)
}
})
}
func TestHTTPAssetFetcherBoundaries(t *testing.T) {
exact := bytes.Repeat([]byte("x"), 100)
t.Run("a body exactly at the cap is accepted with a content length", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(exact) }))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress)
if err != nil {
t.Errorf("err = %v", err)
}
})
t.Run("a chunked body exactly at the cap is accepted", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Transfer-Encoding", "chunked")
_, _ = w.Write(exact)
}))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress)
if err != nil {
t.Errorf("err = %v", err)
}
})
t.Run("checksums over 64 KiB are refused without a custom cap", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(bytes.Repeat([]byte("a"), 64<<10+1))
}))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "checksums.txt", 0), fetchTarget(t), noProgress)
if err == nil {
t.Error("65 KiB of checksums must exceed the cap")
}
})
t.Run("redirects are followed up to four hops and the fifth is refused", func(t *testing.T) {
for _, hops := range []int{4, 5} {
var served atomic.Int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if int(served.Add(1)) <= hops {
http.Redirect(w, r, server.URL+"/next", http.StatusFound)
return
}
_, _ = w.Write(exact)
}))
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress)
server.Close()
if hops == 4 && err != nil {
t.Errorf("%d redirects: err = %v", hops, err)
}
if hops == 5 && err == nil {
t.Errorf("%d redirects must be refused", hops)
}
}
})
}
+704
View File
@@ -0,0 +1,704 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
)
const downloadPath = "/update/download"
func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
t.Helper()
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
users := newUpdateUserRepo()
feed := &fakeReleaseFeed{}
now := func() time.Time { return updateTestCheckedAt }
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: rig.status,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
StartUpdateDownloadUseCase: rig.start,
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: rig.store}
}
func waitForHTTPState(t *testing.T, h *updateHarness, states ...string) map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var body map[string]any
for time.Now().Before(deadline) {
body = decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
for _, state := range states {
if body["state"] == state {
return body
}
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("state never reached %v, last body %v", states, body)
return nil
}
type pausedDownload struct {
reached chan struct{}
release chan struct{}
}
func pauseBinaryAt(rig *downloadRig, done int64, total int64) *pausedDownload {
pause := &pausedDownload{reached: make(chan struct{}), release: make(chan struct{})}
var once sync.Once
rig.fetcher.during = func(name string, report update.ProgressFunc) {
if name != rig.assetName {
return
}
once.Do(func() {
report(done, total)
close(pause.reached)
})
<-pause.release
}
return pause
}
func newBoardSizedRig(t *testing.T) *downloadRig {
t.Helper()
return newDownloadRigFor(t, update.Platform{OS: "linux", Arch: "amd64"}, make([]byte, boardTotalBytes))
}
func TestStartUpdateDownloadHTTP(t *testing.T) {
t.Run("Start the update, happy path", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
gate := make(chan struct{})
rig.fetcher.during = func(name string, _ update.ProgressFunc) {
if name == rig.assetName {
<-gate
}
}
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if body["state"] != "downloading" || body["started_at"] == nil || body["error"] != nil || body["can_apply"] != true {
t.Errorf("body = %v", body)
}
during := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
progress, _ := during["progress"].(map[string]any)
if during["state"] != "downloading" || progress == nil || progress["total_bytes"] != float64(len(rig.fetcher.files[rig.assetName])) {
t.Errorf("during = %v", during)
}
close(gate)
ready := waitForHTTPState(t, h, "ready", "failed")
if ready["state"] != "ready" || ready["error"] != nil || ready["progress"] != nil || ready["latest"] != downloadTag {
t.Fatalf("ready = %v, log: %s", ready, rig.logger.text())
}
for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} {
if _, err := os.Stat(filepath.Join(rig.updatesDir, downloadTag, name)); err != nil {
t.Errorf("staged %s: %v", name, err)
}
}
if rig.backup.callCount() != 1 || rig.backup.seen[0] != update.StateVerifying {
t.Errorf("backup calls = %d, state seen = %v", rig.backup.callCount(), rig.backup.seen)
}
if _, err := os.Stat(filepath.Join(rig.dataDir, "renewcms.db")); err == nil {
t.Error("the database file must not be touched")
}
})
t.Run("Progress is reported in bytes", func(t *testing.T) {
rig := newBoardSizedRig(t)
h := newDownloadHTTP(t, rig)
pause := pauseBinaryAt(rig, boardDoneBytes, boardTotalBytes)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
<-pause.reached
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateAdminId))
close(pause.release)
waitForHTTPState(t, h, "ready", "failed")
progress, _ := body["progress"].(map[string]any)
if body["state"] != "downloading" || progress == nil || progress["percent"] != float64(42) || progress["downloaded_bytes"] != float64(boardDoneBytes) || progress["total_bytes"] != float64(boardTotalBytes) {
t.Errorf("body = %v", body)
}
})
t.Run("Every owner sees the same job", func(t *testing.T) {
rig := newBoardSizedRig(t)
h := newDownloadHTTP(t, rig)
pause := pauseBinaryAt(rig, boardDoneBytes, boardTotalBytes)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
<-pause.reached
owner := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateSecondOwner))
admin := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateAdminId))
close(pause.release)
waitForHTTPState(t, h, "ready", "failed")
if owner["state"] != "downloading" || admin["state"] != "downloading" || owner["started_at"] != admin["started_at"] || owner["started_at"] == nil {
t.Errorf("owner = %v admin = %v", owner, admin)
}
ownerProgress, _ := owner["progress"].(map[string]any)
adminProgress, _ := admin["progress"].(map[string]any)
if ownerProgress == nil || adminProgress == nil || ownerProgress["percent"] != adminProgress["percent"] || ownerProgress["downloaded_bytes"] != adminProgress["downloaded_bytes"] {
t.Errorf("progress differs: %v vs %v", ownerProgress, adminProgress)
}
if owner["can_apply"] != true || admin["can_apply"] != false {
t.Errorf("can_apply owner = %v admin = %v", owner["can_apply"], admin["can_apply"])
}
})
t.Run("Only the owner", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateAdminId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
})
t.Run("POST is guarded like the other system routes", func(t *testing.T) {
cases := []struct {
name string
userId uint32
tokenVersion uint32
status int
}{
{"no jwt cookie", 0, 0, http.StatusUnauthorized},
{"a stale token version", updateOwnerId, 1, http.StatusUnauthorized},
{"an unverified owner", updateUnverifiedOwner, 0, http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rec := h.send(t, http.MethodPost, downloadPath, c.userId, c.tokenVersion)
if rec.Code != c.status {
t.Errorf("status = %d, want %d", rec.Code, c.status)
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start")
}
})
}
})
t.Run("Nothing to update", func(t *testing.T) {
t.Run("available false", func(t *testing.T) {
rig := newDownloadRig(t)
rig.store.result.Latest = updateRunning
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusBadRequest)
})
t.Run("no stored result", func(t *testing.T) {
rig := newDownloadRig(t)
rig.store.found = false
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusBadRequest)
})
})
t.Run("The server is not prepared", func(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusConflict)
if body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId)); body["can_apply"] != false {
t.Errorf("can_apply = %v", body["can_apply"])
}
rig.store.result.Latest = updateRunning
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateSecondOwner), http.StatusConflict)
})
t.Run("A second start while a job exists", func(t *testing.T) {
for _, state := range []string{update.StateDownloading, update.StateVerifying, update.StateReady} {
t.Run(state, func(t *testing.T) {
rig := newDownloadRig(t)
existing := update.Job{Tag: downloadTag, State: state, StartedAt: time.Date(2026, 9, 30, 9, 0, 0, 0, time.UTC)}
rig.jobs.put(existing)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusConflict {
t.Errorf("status = %d", rec.Code)
}
if job, _ := rig.jobs.Load(); job != existing || rig.fetcher.totalCalls() != 0 {
t.Errorf("the existing job was touched: %+v", job)
}
})
}
})
t.Run("Two starts at once run one job", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
gate := make(chan struct{})
rig.fetcher.during = func(name string, _ update.ProgressFunc) {
if name == rig.assetName {
<-gate
}
}
codes := make(chan int, 2)
var ready sync.WaitGroup
ready.Add(2)
for _, owner := range []uint32{updateOwnerId, updateSecondOwner} {
go func() {
ready.Done()
ready.Wait()
codes <- h.sendAs(t, http.MethodPost, downloadPath, owner).Code
}()
}
first, second := <-codes, <-codes
close(gate)
waitForHTTPState(t, h, "ready", "failed")
accepted, conflicts := 0, 0
for _, code := range []int{first, second} {
switch code {
case http.StatusAccepted:
accepted++
case http.StatusConflict:
conflicts++
}
}
if accepted != 1 || conflicts != 1 {
t.Errorf("codes = %d and %d", first, second)
}
for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} {
if rig.fetcher.callsFor(name) != 1 {
t.Errorf("%s fetched %d times", name, rig.fetcher.callsFor(name))
}
}
})
t.Run("Try again after a failure", func(t *testing.T) {
rig := newDownloadRig(t)
failedAt := time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC)
rig.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed, Error: update.ErrorKindDownload, StartedAt: failedAt})
if err := os.MkdirAll(rig.stagedDir(), 0o755); err != nil {
t.Fatal(err)
}
stale := filepath.Join(rig.stagedDir(), "left-over")
if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := decodeUpdateBody(t, rec)
if rec.Code != http.StatusAccepted || body["error"] != nil || body["started_at"] == "2026-09-29T08:00:00Z" || body["state"] != "downloading" {
t.Fatalf("status = %d body = %v", rec.Code, body)
}
waitForHTTPState(t, h, "ready", "failed")
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Error("the old staged files must be replaced")
}
})
t.Run("Start is rate limited per user", func(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
h := newDownloadHTTP(t, rig)
authLimited := middleware.NewRateLimiter(5, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
for attempt := 1; attempt <= 5; attempt++ {
if rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId); rec.Code != http.StatusConflict {
t.Fatalf("attempt %d status = %d", attempt, rec.Code)
}
}
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
retryAfter, err := strconv.Atoi(rec.Header().Get("Retry-After"))
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || err != nil || retryAfter < 1 {
t.Errorf("status = %d body = %q retry-after = %q", rec.Code, rec.Body.String(), rec.Header().Get("Retry-After"))
}
if other := h.sendAs(t, http.MethodPost, downloadPath, updateSecondOwner); other.Code != http.StatusConflict {
t.Errorf("another owner status = %d", other.Code)
}
for attempt := 1; attempt <= 5; attempt++ {
if check := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId); check.Code != http.StatusOK {
t.Errorf("check limiter was consumed: attempt %d status = %d", attempt, check.Code)
}
}
for attempt := 1; attempt <= 5; attempt++ {
recorder := httptest.NewRecorder()
authLimited.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/login", nil))
if recorder.Code != http.StatusOK {
t.Errorf("auth limiter attempt %d status = %d", attempt, recorder.Code)
}
}
})
}
func assertNothingStarted(t *testing.T, rig *downloadRig, rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status {
t.Errorf("status = %d, want %d", rec.Code, status)
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
}
func TestUpdatePreflightRefusalsHTTP(t *testing.T) {
conditions := []struct {
name string
apply func(rig *downloadRig)
}{
{"no asset is named for the host platform", func(rig *downloadRig) { rig.dropAsset(rig.assetName) }},
{"checksums.txt is missing from the assets", func(rig *downloadRig) { rig.dropAsset("checksums.txt") }},
{"checksums.txt.sig is missing from the assets", func(rig *downloadRig) { rig.dropAsset("checksums.txt.sig") }},
{"an asset URL is http, not https", func(rig *downloadRig) {
rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.URL = "http://git.example/dl/" + asset.Name })
}},
{"an asset URL is on a host other than UPDATE_FEED_URL's host", func(rig *downloadRig) {
rig.setAsset("checksums.txt", func(asset *update.Asset) { asset.URL = "https://evil.example/dl/" + asset.Name })
}},
}
for _, condition := range conditions {
t.Run(condition.name, func(t *testing.T) {
rig := newDownloadRig(t)
condition.apply(rig)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != apperr.UpdateRefusedErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
for _, leak := range []string{"git.example", "evil.example", "renewcms_", "checksums"} {
if strings.Contains(rec.Body.String(), leak) {
t.Errorf("body leaks %q", leak)
}
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
if !strings.Contains(rig.logger.text(), "refused") {
t.Errorf("the reason must be logged, log: %q", rig.logger.text())
}
})
}
}
type jobFault struct {
name string
apply func(rig *downloadRig)
logged string
}
func runFaultTable(t *testing.T, wantError string, faults []jobFault) {
t.Helper()
for _, fault := range faults {
t.Run(fault.name, func(t *testing.T) {
rig := newDownloadRig(t)
fault.apply(rig)
h := newDownloadHTTP(t, rig)
post := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if post.Code != http.StatusAccepted {
t.Fatalf("status = %d: %s", post.Code, post.Body.String())
}
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
body := waitForHTTPState(t, h, "failed", "ready")
if body["state"] != "failed" || body["error"] != wantError || body["progress"] != nil {
t.Fatalf("body = %v, log: %s", body, rig.logger.text())
}
assertStagedDirGone(t, rig)
if !strings.Contains(rig.logger.text(), fault.logged) {
t.Errorf("log %q must contain %q", rig.logger.text(), fault.logged)
}
for _, response := range []string{post.Body.String(), rec.Body.String(), h.sendAs(t, http.MethodGet, "/update", updateOwnerId).Body.String()} {
if strings.Contains(response, fault.logged) {
t.Errorf("a response leaks %q", fault.logged)
}
}
for _, state := range rig.jobs.states() {
if state == update.StateReady {
t.Error("a failed job must never have been ready")
}
}
})
}
}
func TestUpdateVerificationFailuresHTTP(t *testing.T) {
runFaultTable(t, "verification", []jobFault{
{"the binary was changed after signing (SHA-256 mismatch)", func(rig *downloadRig) {
tampered := append([]byte(nil), rig.fetcher.files[rig.assetName]...)
tampered[0] ^= 0xff
rig.fetcher.files[rig.assetName] = tampered
}, "sha-256 mismatch"},
{"checksums.txt was changed after signing", func(rig *downloadRig) {
tampered := []byte(strings.Replace(string(rig.fetcher.files["checksums.txt"]), "renewcms_", "renewcmz_", 1))
rig.fetcher.files["checksums.txt"] = tampered
}, "signature"},
{"the signature is from a different key", func(rig *downloadRig) {
_, other, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
rig.signChecksums(string(rig.fetcher.files["checksums.txt"]), other)
}, "does not match"},
{"checksums.txt has no line for the asset", func(rig *downloadRig) {
rig.signChecksums(rig.checksumsFor("renewcms_other_file", []byte("x")), rig.privateKey)
rig.refreshAssetSizes()
}, "has no line"},
{"the staged binary prints v2.2.9-beta for --version", func(rig *downloadRig) {
rig.runner.output = "v2.2.9-beta"
}, "v2.2.9-beta"},
{"the staged checksums cannot be read back", func(rig *downloadRig) {
rig.staging.readErr = errDownloadDisk
}, "no space left on device"},
{"the staged binary cannot be made executable", func(rig *downloadRig) {
rig.staging.chmodErr = errDownloadChmod
}, "operation not permitted"},
{"the staged binary cannot be run", func(rig *downloadRig) {
rig.runner.err = errDownloadRunnerFail
}, "exec format error"},
})
}
func TestUpdateDownloadFailuresHTTP(t *testing.T) {
runFaultTable(t, "download", []jobFault{
{"the connection drops after half the bytes (truncated download)", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
rig.fetcher.partial[rig.assetName] = true
}, "connection reset by peer"},
{"the bytes received differ from the size the release declares", func(rig *downloadRig) {
rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.Size += 7 })
}, "the release declares"},
{"a file exceeds its size cap", func(rig *downloadRig) {
rig.fetcher.errors["checksums.txt"] = errDownloadTooLarge
}, "size cap"},
{"the download passes the overall timeout", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = context.DeadlineExceeded
}, "deadline exceeded"},
{"the feed host answers 404 or 500 for an asset", func(rig *downloadRig) {
rig.fetcher.errors["checksums.txt.sig"] = errAssetStatusNotFound
}, "answered 404"},
{"the updates directory cannot be created or written (disk error)", func(rig *downloadRig) {
if err := os.WriteFile(rig.updatesDir, []byte("a file where the directory should be"), 0o644); err != nil {
t.Fatal(err)
}
}, "updates"},
{"the staging directory cannot be prepared (injected)", func(rig *downloadRig) {
rig.staging.prepareErr = errDownloadDisk
}, "no space left on device"},
{"a staged file cannot be measured", func(rig *downloadRig) {
rig.staging.sizeErr = errDownloadStat
}, "stat failed"},
{"the disk fills during the write", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = errDownloadDisk
rig.fetcher.partial[rig.assetName] = true
}, "no space left on device"},
{"the database backup fails", func(rig *downloadRig) {
rig.backup.err = errDownloadBackup
}, "vacuum into failed"},
})
}
func TestUpdateBackupBeforeReadyHTTP(t *testing.T) {
t.Run("The backup comes before ready, and the last 3 are kept", func(t *testing.T) {
rig := newDownloadRig(t)
backups := filepath.Join(rig.dataDir, "backups")
if err := os.MkdirAll(backups, 0o755); err != nil {
t.Fatal(err)
}
for index, name := range []string{"earlier-1.db", "earlier-2.db", "earlier-3.db"} {
path := filepath.Join(backups, name)
if err := os.WriteFile(path, []byte("older copy"), 0o600); err != nil {
t.Fatal(err)
}
backdate(t, path, time.Duration(10-index)*time.Hour)
}
rig.realBackup = infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), backups, time.Now)
rig.rebuild()
h := newDownloadHTTP(t, rig)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := waitForHTTPState(t, h, "ready", "failed")
if body["state"] != "ready" {
t.Fatalf("body = %v, log: %s", body, rig.logger.text())
}
entries, err := os.ReadDir(backups)
if err != nil || len(entries) != 3 {
t.Fatalf("backups = %d, err = %v", len(entries), err)
}
if _, err := os.Stat(filepath.Join(backups, "earlier-1.db")); !os.IsNotExist(err) {
t.Error("the oldest backup must be deleted")
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "renewcms-") {
if rows := countBackupRows(t, filepath.Join(backups, entry.Name())); rows != 2 {
t.Errorf("rows in the new copy = %d, want 2", rows)
}
}
}
})
t.Run("a failed backup leaves state failed and does not prune", func(t *testing.T) {
rig := newDownloadRig(t)
rig.backup.err = errDownloadBackup
h := newDownloadHTTP(t, rig)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := waitForHTTPState(t, h, "ready", "failed")
if body["state"] != "failed" || body["error"] != "download" {
t.Errorf("body = %v", body)
}
})
}
func TestFailedUpdateJobRemovesItsStagedDirectoryEvenWhenRemovalFails(t *testing.T) {
rig := newDownloadRig(t)
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
rig.staging.removeErr = errDownloadDisk
if _, err := rig.start.Start(); err != nil {
t.Fatal(err)
}
job := rig.waitForJob(update.StateFailed)
if job.Error != update.ErrorKindDownload || job.StagedBinary != "" {
t.Errorf("job = %+v", job)
}
if got := rig.staging.removed(); len(got) != 1 || got[0] != downloadTag {
t.Errorf("removed tags = %v", got)
}
}
func TestOSStagingArea(t *testing.T) {
root := filepath.Join(t.TempDir(), "updates")
area := infraUpdate.NewOSStagingArea(root)
t.Run("paths are rooted under the tag", func(t *testing.T) {
if got, want := area.PathFor("v1", "a.bin"), filepath.Join(root, "v1", "a.bin"); got != want {
t.Errorf("path = %q, want %q", got, want)
}
})
t.Run("prepare empties and recreates the directory", func(t *testing.T) {
if err := area.Prepare("v1"); err != nil {
t.Fatal(err)
}
stale := area.PathFor("v1", "stale")
if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := area.Prepare("v1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale file survived: %v", err)
}
if info, err := os.Stat(filepath.Join(root, "v1")); err != nil || !info.IsDir() {
t.Errorf("directory missing: %v", err)
}
})
t.Run("size and checksums read the staged file", func(t *testing.T) {
path := area.PathFor("v1", "checksums.txt")
if err := os.WriteFile(path, []byte("abc"), 0o644); err != nil {
t.Fatal(err)
}
if size, err := area.Size(path); err != nil || size != 3 {
t.Errorf("size = %d, err = %v", size, err)
}
if content, err := area.ReadChecksums(path); err != nil || content != "abc" {
t.Errorf("content = %q, err = %v", content, err)
}
missing := area.PathFor("v1", "missing")
if _, err := area.Size(missing); err == nil {
t.Error("size of a missing file must fail")
}
if _, err := area.ReadChecksums(missing); err == nil {
t.Error("reading a missing file must fail")
}
})
t.Run("make executable sets the mode and fails on a missing file", func(t *testing.T) {
path := area.PathFor("v1", "binary")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := area.MakeExecutable(path); err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o755 {
t.Errorf("mode = %v, err = %v", info.Mode(), err)
}
}
if err := area.MakeExecutable(area.PathFor("v1", "missing")); err == nil {
t.Error("a missing file must fail")
}
})
t.Run("remove deletes the tag directory only", func(t *testing.T) {
if err := area.Prepare("v2"); err != nil {
t.Fatal(err)
}
if err := area.Remove("v1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "v1")); !os.IsNotExist(err) {
t.Errorf("v1 still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "v2")); err != nil {
t.Errorf("v2 must remain: %v", err)
}
})
t.Run("prepare fails when a file sits where the root should be", func(t *testing.T) {
blocked := filepath.Join(t.TempDir(), "updates")
if err := os.WriteFile(blocked, []byte("file"), 0o644); err != nil {
t.Fatal(err)
}
if err := infraUpdate.NewOSStagingArea(blocked).Prepare("v1"); err == nil {
t.Error("prepare must fail")
}
})
}
File diff suppressed because it is too large. Load diff
+13 -6
View File
@@ -24,6 +24,7 @@ const (
updateAdminId = 2
updateUnverifiedId = 3
updateSecondOwner = 4
updateUnverifiedOwner = 5
updateRunning = "v2.2.0-beta"
)
@@ -36,14 +37,20 @@ type updateHarness struct {
store *fakeUpdateStore
}
func newUpdateHarness(t *testing.T, running string) *updateHarness {
t.Helper()
validator.InitValidator()
func newUpdateUserRepo() *mockUserRepo {
users := newMockUserRepo()
users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin}
users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, Role: user.RoleAdmin}
users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, Role: user.RoleOwner}
users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, Role: user.RoleOwner}
return users
}
func newUpdateHarness(t *testing.T, running string) *updateHarness {
t.Helper()
validator.InitValidator()
users := newUpdateUserRepo()
feed := &fakeReleaseFeed{release: update.Release{
Tag: "v2.3.0-beta",
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
@@ -56,7 +63,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, running),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
@@ -306,9 +313,9 @@ func TestUpdateCheckHTTP(t *testing.T) {
}
})
t.Run("download and restart are not mounted", func(t *testing.T) {
t.Run("restart is not mounted", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
for _, path := range []string{"/update/download", "/update/restart"} {
for _, path := range []string{"/update/restart"} {
if rec := h.sendAs(t, http.MethodPost, path, updateOwnerId); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s status = %d", path, rec.Code)
}
+3 -3
View File
@@ -154,7 +154,7 @@ func TestReleaseNotesStripInlineMarkdown(t *testing.T) {
func TestGetUpdateStatusUseCase(t *testing.T) {
t.Run("never checked", func(t *testing.T) {
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, "v2.2.0-beta").GetStatus()
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true)
if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available {
t.Errorf("status = %+v", status)
@@ -170,7 +170,7 @@ func TestGetUpdateStatusUseCase(t *testing.T) {
t.Run("stored result", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
status := useCases.NewGetUpdateStatusUseCase(store, "v2.2.0-beta").GetStatus()
status := useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true)
if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle {
t.Errorf("status = %+v", status)
@@ -185,7 +185,7 @@ func TestGetUpdateStatusUseCase(t *testing.T) {
t.Run("dev build never reports an update", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
if useCases.NewGetUpdateStatusUseCase(store, "dev").GetStatus().Available {
if useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "dev").GetStatus(true).Available {
t.Error("a dev build must not report an update")
}
})
+23 -5
View File
@@ -1,11 +1,29 @@
<script setup lang="ts">
export type UpdateBannerState = 'available'
import { computed } from 'vue'
defineProps<{
export type UpdateBannerState = 'available' | 'ready'
const props = defineProps<{
state: UpdateBannerState
version: string
}>()
const COPY = {
available: {
icon: 'i-lucide-circle-arrow-up',
title: (version: string) => `Version ${version} is available`,
description: 'Update it from Settings. It takes about 2 minutes and the site stays online.',
},
ready: {
icon: 'i-lucide-circle-check',
title: (version: string) => `Version ${version} is ready to install`,
description:
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
},
} as const
const copy = computed(() => COPY[props.state])
const emit = defineEmits<{
'go-to-settings': []
dismiss: []
@@ -19,9 +37,9 @@ const emit = defineEmits<{
color="info"
variant="soft"
orientation="horizontal"
icon="i-lucide-circle-arrow-up"
:title="`Version ${version} is available`"
description="Update it from Settings. It takes about 2 minutes and the site stays online."
:icon="copy.icon"
:title="copy.title(version)"
:description="copy.description"
:ui="{
root: 'flex-wrap items-start gap-x-2.5 gap-y-3 border border-default py-3 ring-0 md:flex-nowrap md:items-center',
wrapper: 'basis-0 gap-0.5',
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
defineProps<{
label: string
text: string
value: number | null
}>()
</script>
<template>
<div aria-live="off" class="flex flex-col gap-1.5 pt-2">
<UProgress
:model-value="value"
:max="100"
:get-value-label="() => label"
:ui="{
base: 'h-1.5 bg-accented',
indicator: value === null ? 'h-full w-7/20 bg-primary' : 'bg-primary',
}"
/>
<span class="font-mono text-xs/4 text-muted">{{ text }}</span>
</div>
</template>
+3 -1
View File
@@ -17,6 +17,7 @@ const props = defineProps<{
title: string
text: string
meta?: string
live?: 'polite' | 'off'
}>()
const toneClasses = computed(() => TONE_CLASSES[props.tone])
@@ -24,7 +25,7 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
</script>
<template>
<div aria-live="polite" class="flex gap-3 p-3.5" :class="toneClasses.block">
<div :aria-live="live ?? 'polite'" class="flex gap-3 p-3.5" :class="toneClasses.block">
<span class="flex pt-0.5">
<UIcon
:name="icon"
@@ -38,6 +39,7 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-base font-semibold text-highlighted">{{ title }}</span>
<span class="text-sm text-pretty text-toned">{{ text }}</span>
<slot />
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
</div>
</div>
+259 -7
View File
@@ -1,18 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import ReleaseNotes from '@/components/ReleaseNotes.vue'
import ServerCommand from '@/components/ServerCommand.vue'
import UpdateProgress from '@/components/UpdateProgress.vue'
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
import type { UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import type { UpdateCheckPhase } from '@/stores/update'
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
import {
ENABLE_BROWSER_UPDATES_COMMAND,
VERIFYING_ANNOUNCEMENT,
displayVersion,
downloadAnnouncement,
formatDownloadProgress,
formatLastChecked,
formatReleased,
} from '@/lib/updateFormat'
type ApplyView =
| 'available'
| 'downloading'
| 'verifying'
| 'ready'
| 'download-failed'
| 'verification-failed'
| 'limited'
type ButtonAction = 'check' | 'update'
interface Block {
tone: UpdateTone
icon: string
@@ -26,26 +41,208 @@ interface ActionButton {
icon: string
primary: boolean
disabled: boolean
action: ButtonAction | null
}
interface ProgressView {
label: string
text: string
value: number | null
}
const AVAILABLE_HINT = 'Takes about 2 minutes. Your site stays online.'
const READY_HINT =
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.'
const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
const props = defineProps<{
status: UpdateStatus
role: UserRole | undefined
phase: UpdateCheckPhase
applyPhase: UpdateApplyPhase
retryLabel: string
retryActive: boolean
}>()
const emit = defineEmits<{
check: []
update: []
}>()
const heading = ref<HTMLElement | null>(null)
const isOwner = computed(() => props.role === 'owner')
const newVersion = computed(() => displayVersion(props.status.latest))
const lastChecked = computed(() =>
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
)
const canApplyView = computed(() => isOwner.value && props.status.can_apply)
const applyView = computed<ApplyView | null>(() => {
if (!canApplyView.value || props.phase !== 'idle') {
return null
}
if (props.applyPhase === 'limited') {
return 'limited'
}
if (props.applyPhase === 'failed') {
return 'download-failed'
}
if (props.applyPhase === 'untrusted') {
return 'verification-failed'
}
const { state, error, available } = props.status
if (state === 'downloading' || state === 'verifying' || state === 'ready') {
return state
}
if (state === 'failed') {
return error === 'verification' ? 'verification-failed' : 'download-failed'
}
return available ? 'available' : null
})
const isStarting = computed(() => props.applyPhase === 'starting')
const downloadFailed = computed(() => applyView.value === 'download-failed' && !isStarting.value)
watch(downloadFailed, async (failed) => {
if (failed) {
await nextTick()
heading.value?.focus()
}
})
const applyBlocks = computed<Record<ApplyView, Block>>(() => ({
available: {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Update downloads the new version and checks it. Nothing changes until you restart.`,
meta: lastChecked.value,
},
downloading: {
tone: 'info',
icon: 'i-lucide-download',
title: `Downloading version ${newVersion.value}…`,
text: 'You can leave this page. The download continues and your site stays online.',
},
verifying: {
tone: 'info',
icon: 'i-lucide-shield-check',
title: 'Checking the download…',
text: 'Making sure the file is complete and safe to install. This takes a few seconds.',
},
ready: {
tone: 'success',
icon: 'i-lucide-circle-check',
title: `Version ${newVersion.value} is ready to install`,
text: 'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
meta: 'Nothing has changed yet.',
},
'download-failed': {
tone: 'error',
icon: 'i-lucide-circle-x',
title: "The download didn't finish",
text: 'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
},
'verification-failed': {
tone: 'warning',
icon: 'i-lucide-triangle-alert',
title: "We didn't use this update",
text: "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.",
},
limited: {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
},
}))
const progressView = computed<ProgressView | null>(() => {
if (applyView.value === 'downloading') {
const progress = props.status.progress ?? ZERO_PROGRESS
return {
label: `Downloading version ${newVersion.value}`,
text: props.status.progress ? formatDownloadProgress(progress) : '0%',
value: progress.percent,
}
}
if (applyView.value === 'verifying') {
return { label: VERIFYING_ANNOUNCEMENT, text: 'Almost there', value: null }
}
return null
})
const announcement = computed(() => {
if (applyView.value === 'downloading') {
return downloadAnnouncement(newVersion.value, props.status.progress?.percent ?? 0)
}
return applyView.value === 'verifying' ? VERIFYING_ANNOUNCEMENT : ''
})
const hint = computed(() => {
if (applyView.value === 'available') {
return AVAILABLE_HINT
}
return applyView.value === 'ready' ? READY_HINT : null
})
function applyButton(view: ApplyView): ActionButton {
if (isStarting.value || view === 'downloading' || view === 'verifying') {
return {
label: 'Updating…',
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
action: null,
}
}
if (view === 'ready') {
return {
label: 'Restart',
icon: 'i-lucide-power',
primary: true,
disabled: true,
action: null,
}
}
if (view === 'limited') {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
action: 'update',
}
}
if (view === 'available') {
return {
label: 'Update',
icon: 'i-lucide-download',
primary: true,
disabled: false,
action: 'update',
}
}
return {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
action: 'update',
}
}
const block = computed<Block>(() => {
if (isOwner.value && props.phase === 'checking') {
return {
@@ -72,6 +269,9 @@ const block = computed<Block>(() => {
meta: lastChecked.value,
}
}
if (applyView.value) {
return applyBlocks.value[applyView.value]
}
if (props.status.available && isOwner.value) {
return {
tone: 'info',
@@ -100,7 +300,11 @@ const block = computed<Block>(() => {
})
const showServerGuidance = computed(
() => isOwner.value && props.status.available && props.phase === 'idle',
() =>
isOwner.value &&
!props.status.can_apply &&
props.status.available &&
props.phase === 'idle',
)
const actionButton = computed<ActionButton | null>(() => {
@@ -113,10 +317,17 @@ const actionButton = computed<ActionButton | null>(() => {
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
action: null,
}
}
if (props.phase === 'failed') {
return { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: true, disabled: false }
return {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: true,
disabled: false,
action: 'check',
}
}
if (props.phase === 'limited') {
return props.retryActive
@@ -125,8 +336,18 @@ const actionButton = computed<ActionButton | null>(() => {
icon: 'i-lucide-clock',
primary: false,
disabled: true,
action: null,
}
: { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: false, disabled: false }
: {
label: 'Try again',
icon: 'i-lucide-rotate-cw',
primary: false,
disabled: false,
action: 'check',
}
}
if (applyView.value) {
return applyButton(applyView.value)
}
if (props.status.available) {
return null
@@ -136,12 +357,28 @@ const actionButton = computed<ActionButton | null>(() => {
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
action: 'check',
}
})
function onAction() {
const action = actionButton.value?.action
if (action === 'check') {
emit('check')
}
if (action === 'update') {
emit('update')
}
}
const showNotes = computed(
() =>
showServerGuidance.value || applyView.value === 'available' || applyView.value === 'ready',
)
const notes = computed(() => {
const items = props.status.notes?.items ?? []
if (!showServerGuidance.value || items.length === 0 || !props.status.notes) {
if (!showNotes.value || items.length === 0 || !props.status.notes) {
return null
}
return {
@@ -159,6 +396,7 @@ const notes = computed(() => {
>
<h2
id="updates-h"
ref="heading"
tabindex="-1"
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
>
@@ -171,7 +409,19 @@ const notes = computed(() => {
:title="block.title"
:text="block.text"
:meta="block.meta"
:live="progressView ? 'off' : 'polite'"
>
<UpdateProgress
v-if="progressView"
:label="progressView.label"
:text="progressView.text"
:value="progressView.value"
/>
</UpdateStatusBlock>
<p v-if="canApplyView" role="status" data-testid="update-announcer" class="sr-only">
{{ announcement }}
</p>
<ReleaseNotes v-if="notes" :version="newVersion" :date="notes.date" :items="notes.items" />
@@ -190,12 +440,14 @@ const notes = computed(() => {
: 'border border-accented bg-default text-highlighted ring-0'
"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('check')"
@click="onAction"
>
{{ actionButton.label }}
</UButton>
</div>
<span v-if="hint" class="text-xs/4.5 text-pretty text-muted">{{ hint }}</span>
<a
v-if="showServerGuidance && status.release_url"
:href="status.release_url"
+15 -3
View File
@@ -15,6 +15,14 @@ export interface UpdateNotes {
items: string[]
}
export interface UpdateProgress {
percent: number
downloaded_bytes: number
total_bytes: number
}
export type UpdateErrorKind = 'download' | 'verification'
export interface UpdateStatus {
current: string
latest: string
@@ -24,8 +32,8 @@ export interface UpdateStatus {
checked_at: string | null
can_apply: boolean
state: UpdateState
progress: unknown
error: string | null
progress: UpdateProgress | null
error: UpdateErrorKind | null
started_at: string | null
}
@@ -40,5 +48,9 @@ export function useUpdate() {
return request<UpdateStatus>('/v1/system/update/check', { method: 'POST' })
}
return { getUpdateStatus, checkForUpdate }
function startUpdateDownload(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update/download', { method: 'POST' })
}
return { getUpdateStatus, checkForUpdate, startUpdateDownload }
}
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
AUTO_CHECK_INTERVAL_MS,
displayVersion,
downloadAnnouncement,
formatDownloadProgress,
formatLastChecked,
formatReleased,
isCheckDue,
@@ -64,3 +66,29 @@ describe('formatReleased', () => {
expect(formatReleased('2026-09-24T08:00:00Z')).toBe('Released 24 Sep 2026')
})
})
describe('formatDownloadProgress', () => {
it('reads the board sample as 42% and 11 of 26 MB', () => {
expect(
formatDownloadProgress({
percent: 42,
downloaded_bytes: 11534336,
total_bytes: 27262976,
}),
).toBe('42% · 11 of 26 MB')
})
})
describe('downloadAnnouncement', () => {
it.each([
[0, 'Downloading version 2.3.0-beta'],
[24, 'Downloading version 2.3.0-beta'],
[25, 'Downloading version 2.3.0-beta, 25 percent'],
[49, 'Downloading version 2.3.0-beta, 25 percent'],
[50, 'Downloading version 2.3.0-beta, 50 percent'],
[75, 'Downloading version 2.3.0-beta, 75 percent'],
[100, 'Downloading version 2.3.0-beta, 75 percent'],
])('reads %i percent as %s', (percent, expected) => {
expect(downloadAnnouncement('2.3.0-beta', percent)).toBe(expected)
})
})
+25
View File
@@ -44,3 +44,28 @@ export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
export function formatReleased(iso: string) {
return `Released ${formatAbsoluteDateFromIso(iso)}`
}
const BYTES_PER_MB = 1024 * 1024
const ANNOUNCEMENT_STEP_PERCENT = 25
const LAST_ANNOUNCED_PERCENT = 75
export function formatDownloadProgress(progress: {
percent: number
downloaded_bytes: number
total_bytes: number
}) {
const done = Math.round(progress.downloaded_bytes / BYTES_PER_MB)
const total = Math.round(progress.total_bytes / BYTES_PER_MB)
return `${progress.percent}% · ${done} of ${total} MB`
}
export function downloadAnnouncement(version: string, percent: number) {
const step = Math.min(
Math.floor(percent / ANNOUNCEMENT_STEP_PERCENT) * ANNOUNCEMENT_STEP_PERCENT,
LAST_ANNOUNCED_PERCENT,
)
const base = `Downloading version ${version}`
return step > 0 ? `${base}, ${step} percent` : base
}
export const VERIFYING_ANNOUNCEMENT = 'Checking the download'
+137 -1
View File
@@ -3,7 +3,16 @@ import { createPinia, setActivePinia } from 'pinia'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
import { CHECK_PATH, STATUS_PATH, newerAvailable, upToDate } from '@/views/__tests__/updateTestKit'
import {
CHECK_PATH,
DOWNLOAD_PATH,
STATUS_PATH,
downloading,
newerAvailable,
ready,
startable,
upToDate,
} from '@/views/__tests__/updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -136,3 +145,130 @@ describe('banner dismissal', () => {
expect(store.bannerVisible).toBe(false)
})
})
describe('startDownload', () => {
it('stores the 202 body and returns to idle', async () => {
const store = setup('owner', { [DOWNLOAD_PATH]: () => Promise.resolve(downloading()) })
const result = await store.startDownload()
expect(result).toBe('started')
expect(store.applyPhase).toBe('idle')
expect(store.status?.state).toBe('downloading')
expect(store.jobRunning).toBe(true)
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' })
})
it('answers busy and sends nothing while a start is in flight', async () => {
const store = setup('owner', { [DOWNLOAD_PATH]: () => new Promise(() => undefined) })
store.startDownload()
expect(await store.startDownload()).toBe('busy')
expect(store.applyPhase).toBe('starting')
expect(requestMock).toHaveBeenCalledTimes(1)
})
it.each([
{ name: '400', error: new ApiError(400), result: 'nothing', phase: 'idle', retry: 0 },
{ name: '403', error: new ApiError(403), result: 'forbidden', phase: 'idle', retry: 0 },
{ name: '409', error: new ApiError(409), result: 'conflict', phase: 'idle', retry: 0 },
{
name: '422',
error: new ApiError(422),
result: 'untrusted',
phase: 'untrusted',
retry: 0,
},
{
name: '429 with Retry-After',
error: new ApiError(429, 42),
result: 'limited',
phase: 'limited',
retry: 42,
},
{
name: '429 without Retry-After',
error: new ApiError(429),
result: 'limited',
phase: 'limited',
retry: 60,
},
{ name: '500', error: new ApiError(500), result: 'failed', phase: 'failed', retry: 0 },
{
name: 'a network failure',
error: new NetworkError(),
result: 'failed',
phase: 'failed',
retry: 0,
},
])('maps $name', async ({ error, result, phase, retry }) => {
const store = setup('owner', { [DOWNLOAD_PATH]: () => Promise.reject(error) })
expect(await store.startDownload()).toBe(result)
expect(store.applyPhase).toBe(phase)
expect(store.retryAfter).toBe(retry)
})
it('forgets a failed start when the status is loaded again', async () => {
const store = setup('owner', {
[DOWNLOAD_PATH]: () => Promise.reject(new ApiError(500)),
[STATUS_PATH]: () => Promise.resolve(startable()),
})
await store.startDownload()
await store.load()
expect(store.applyPhase).toBe('idle')
})
})
describe('poll', () => {
it('replaces the status and keeps it when a poll fails', async () => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(downloading(60)) })
await store.poll()
expect(store.status?.progress?.percent).toBe(60)
requestMock.mockImplementation(() => Promise.reject(new ApiError(500)))
await store.poll()
expect(store.status?.progress?.percent).toBe(60)
expect(store.loadState).toBe('idle')
})
it('skips a poll while another is in flight', async () => {
const store = setup('owner', { [STATUS_PATH]: () => new Promise(() => undefined) })
store.poll()
await store.poll()
expect(requestMock).toHaveBeenCalledTimes(1)
})
})
describe('job state', () => {
it.each([
{ name: 'downloading', status: downloading(), running: true, readyToInstall: false },
{ name: 'ready', status: ready(), running: false, readyToInstall: true },
{ name: 'idle', status: startable(), running: false, readyToInstall: false },
{
name: 'downloading without the helper',
status: { ...downloading(), can_apply: false },
running: false,
readyToInstall: false,
},
])('reads $name', async ({ status, running, readyToInstall }) => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(status) })
await store.load()
expect(store.jobRunning).toBe(running)
expect(store.readyToInstall).toBe(readyToInstall)
})
it('shows no ready state to an admin', async () => {
const store = setup('admin', { [STATUS_PATH]: () => Promise.resolve(ready()) })
await store.load()
expect(store.readyToInstall).toBe(false)
expect(store.jobRunning).toBe(false)
})
})
+80
View File
@@ -7,6 +7,16 @@ import { useSessionStore } from '@/stores/session'
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
export type UpdateCheckResult = 'updated' | 'failed' | 'limited' | 'forbidden' | 'busy'
export type UpdateApplyPhase = 'idle' | 'starting' | 'failed' | 'untrusted' | 'limited'
export type UpdateStartResult =
| 'started'
| 'failed'
| 'untrusted'
| 'limited'
| 'forbidden'
| 'nothing'
| 'conflict'
| 'busy'
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
const DEFAULT_RETRY_SECONDS = 60
@@ -32,6 +42,7 @@ export const useUpdateStore = defineStore('update', () => {
const status = ref<UpdateStatus | null>(null)
const loadState = ref<UpdateLoadState>('idle')
const phase = ref<UpdateCheckPhase>('idle')
const applyPhase = ref<UpdateApplyPhase>('idle')
const retryAfter = ref(0)
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
@@ -42,6 +53,13 @@ export const useUpdateStore = defineStore('update', () => {
const bannerVisible = computed(
() => showCue.value && status.value?.latest !== dismissedVersion.value,
)
const jobRunning = computed(
() =>
isOwner.value &&
status.value?.can_apply === true &&
(status.value.state === 'downloading' || status.value.state === 'verifying'),
)
const readyToInstall = computed(() => isOwner.value && status.value?.state === 'ready')
function load() {
if (pendingLoad) {
@@ -49,6 +67,9 @@ export const useUpdateStore = defineStore('update', () => {
}
const { getUpdateStatus } = useUpdate()
loadState.value = 'loading'
if (applyPhase.value !== 'starting') {
applyPhase.value = 'idle'
}
pendingLoad = Promise.resolve()
.then(getUpdateStatus)
.then((result) => {
@@ -64,6 +85,60 @@ export const useUpdateStore = defineStore('update', () => {
return pendingLoad
}
let pendingPoll = false
async function poll() {
if (pendingPoll) {
return
}
const { getUpdateStatus } = useUpdate()
pendingPoll = true
try {
status.value = await getUpdateStatus()
} catch {
return
} finally {
pendingPoll = false
}
}
async function startDownload(): Promise<UpdateStartResult> {
if (applyPhase.value === 'starting') {
return 'busy'
}
const { startUpdateDownload } = useUpdate()
applyPhase.value = 'starting'
try {
status.value = await startUpdateDownload()
loadState.value = 'ready'
applyPhase.value = 'idle'
return 'started'
} catch (error) {
return settleStartFailure(error)
}
}
function settleStartFailure(error: unknown): UpdateStartResult {
const outcomes: Record<number, UpdateStartResult> = {
400: 'nothing',
403: 'forbidden',
409: 'conflict',
422: 'untrusted',
429: 'limited',
}
const outcome = error instanceof ApiError ? outcomes[error.status] : undefined
if (outcome === 'limited' && error instanceof ApiError) {
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
}
const phases: Partial<Record<UpdateStartResult, UpdateApplyPhase>> = {
limited: 'limited',
untrusted: 'untrusted',
}
const result = outcome ?? 'failed'
applyPhase.value = phases[result] ?? (result === 'failed' ? 'failed' : 'idle')
return result
}
async function check(): Promise<UpdateCheckResult> {
if (phase.value === 'checking') {
return 'busy'
@@ -101,12 +176,17 @@ export const useUpdateStore = defineStore('update', () => {
status,
loadState,
phase,
applyPhase,
retryAfter,
isOwner,
available,
showCue,
bannerVisible,
jobRunning,
readyToInstall,
load,
poll,
startDownload,
check,
dismissBanner,
}
+20 -3
View File
@@ -115,6 +115,23 @@ function onArticleCreated(article: Article) {
})
}
const readyBannerDismissed = ref(false)
const bannerState = computed(() => {
if (update.readyToInstall) {
return readyBannerDismissed.value ? null : 'ready'
}
return update.bannerVisible ? 'available' : null
})
function dismissBanner() {
if (bannerState.value === 'ready') {
readyBannerDismissed.value = true
return
}
update.dismissBanner()
}
watch(
() => update.isOwner,
(isOwner) => {
@@ -160,11 +177,11 @@ onMounted(loadArticles)
</div>
<UpdateBanner
v-if="update.bannerVisible && update.status"
state="available"
v-if="bannerState && update.status"
:state="bannerState"
:version="displayVersion(update.status.latest)"
@go-to-settings="router.push('/settings')"
@dismiss="update.dismissBanner"
@dismiss="dismissBanner"
/>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
+80 -11
View File
@@ -12,6 +12,7 @@ import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
const SKELETON_DELAY_MS = 150
const POLL_INTERVAL_MS = 1000
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
type LoadState = 'loading' | 'ready' | 'error'
@@ -85,19 +86,60 @@ const updatesStatus = computed(() => {
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
async function onCheck() {
const result = await update.check()
if (result !== 'forbidden') {
return
}
async function reloadAfterRoleChange() {
await session.refresh()
await update.load()
}
function notifyOnlyOwner() {
toast.add({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'Only the owner can do this',
description: 'Ask the owner of this site to update RenewCMS.',
})
await session.refresh()
}
async function onUpdate() {
const result = await update.startDownload()
if (result === 'limited') {
return
}
if (result === 'forbidden') {
notifyOnlyOwner()
await reloadAfterRoleChange()
return
}
if (result === 'nothing') {
toast.add({
color: 'info',
icon: 'i-lucide-info',
title: 'Nothing to update',
description: 'You already have the latest version.',
})
await update.load()
return
}
if (result === 'conflict') {
await update.load()
if (update.jobRunning) {
toast.add({
color: 'info',
icon: 'i-lucide-info',
title: 'An update is already running',
description: 'Another owner started it. Progress shows here.',
})
}
}
}
async function onCheck() {
const result = await update.check()
if (result !== 'forbidden') {
return
}
notifyOnlyOwner()
await reloadAfterRoleChange()
}
const updateRequested = ref(false)
@@ -115,7 +157,8 @@ watch(
return
}
autoCheckHandled = true
if (update.isOwner && isCheckDue(update.status?.checked_at ?? null)) {
const jobActive = update.jobRunning || update.readyToInstall
if (update.isOwner && !jobActive && isCheckDue(update.status?.checked_at ?? null)) {
onCheck()
}
},
@@ -123,20 +166,44 @@ watch(
)
watch(
() => update.phase,
(phase) => {
if (phase === 'limited') {
() => [update.phase, update.applyPhase] as const,
([phase, applyPhase]) => {
if (phase === 'limited' || applyPhase === 'limited') {
retry.start(update.retryAfter)
}
},
)
let pollTimer: ReturnType<typeof setInterval> | null = null
function stopPolling() {
if (pollTimer !== null) {
clearInterval(pollTimer)
pollTimer = null
}
}
function startPolling() {
if (pollTimer === null) {
pollTimer = setInterval(update.poll, POLL_INTERVAL_MS)
}
}
watch(
() => update.jobRunning,
(running) => (running ? startPolling() : stopPolling()),
{ immediate: true },
)
onMounted(async () => {
loadSettings()
await update.load()
updateRequested.value = true
})
onBeforeUnmount(clearSkeletonTimer)
onBeforeUnmount(() => {
clearSkeletonTimer()
stopPolling()
})
</script>
<template>
@@ -198,9 +265,11 @@ onBeforeUnmount(clearSkeletonTimer)
:status="updatesStatus"
:role="session.session?.role"
:phase="updatesPhase"
:apply-phase="update.applyPhase"
:retry-label="retry.formatted.value"
:retry-active="retry.active.value"
@check="onCheck"
@update="onUpdate"
/>
</div>
</DashboardShell>
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import type { UserRole } from '@/composables/useApi'
import type { UpdateStatus } from '@/composables/useUpdate'
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
import { useSessionStore } from '@/stores/session'
import HomeView from '../HomeView.vue'
import { NEW_TAG, downloading, newerAvailable, ready, startable, verifying } from './updateTestKit'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let wrapper: VueWrapper | null = null
async function mountHome(role: UserRole, status: UpdateStatus) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/') {
return Promise.resolve(mockResponse(200, []))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
}
throw new Error(`Unexpected fetch call: ${url}`)
}) as unknown as typeof fetch
await router.push('/home')
wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function banner() {
return document.body.querySelector('[aria-label="Update available"][role="region"]')
}
function settingsNavItem() {
return document.body.querySelector('nav[aria-label="Main"] a[href="/settings"]')!
}
function mobileDot() {
return document.body.querySelector('[role="img"][aria-label="Update available"]')
}
function buttonByLabel(label: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) =>
button.textContent?.trim() === label || button.getAttribute('aria-label') === label,
)
}
async function remount(role: UserRole, status: UpdateStatus) {
wrapper?.unmount()
document.body.innerHTML = ''
await mountHome(role, status)
}
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
document.body.innerHTML = ''
})
describe('Home banner, ready to install (owner)', () => {
it('shows the info region with the ready copy, Go to Settings and Dismiss', async () => {
await mountHome('owner', ready())
const region = banner()!
expect(region).not.toBeNull()
expect(region.textContent).toContain('Version 2.3.0-beta is ready to install')
expect(region.textContent).toContain(
'Restart from Settings to finish. The site is unavailable for about 10 seconds.',
)
expect(region.textContent).not.toContain('is available')
expect(region.textContent).toContain('Go to Settings')
expect(region.querySelector('button[aria-label="Dismiss"]')).not.toBeNull()
expect(region.querySelector('svg')).not.toBeNull()
})
it('goes to /settings from Go to Settings', async () => {
await mountHome('owner', ready())
buttonByLabel('Go to Settings')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings'))
})
it('shows although the available banner for this version was dismissed earlier', async () => {
localStorage.setItem(DISMISSED_VERSION_KEY, NEW_TAG)
await mountHome('owner', ready())
expect(banner()!.textContent).toContain('is ready to install')
})
it('hides on dismiss for this visit only and writes nothing to localStorage', async () => {
const setItem = vi.spyOn(Storage.prototype, 'setItem')
await mountHome('owner', ready())
const writesBefore = setItem.mock.calls.length
buttonByLabel('Dismiss')!.click()
await flushPromises()
expect(banner()).toBeNull()
expect(setItem.mock.calls).toHaveLength(writesBefore)
expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBeNull()
await remount('owner', ready())
expect(banner()!.textContent).toContain('is ready to install')
setItem.mockRestore()
})
it('is not shown to an admin', async () => {
await mountHome('admin', ready())
expect(banner()).toBeNull()
})
it.each([
{ name: 'downloading', status: downloading() },
{ name: 'verifying', status: verifying() },
])('keeps the available banner while $name', async ({ status }) => {
await mountHome('owner', status)
expect(banner()!.textContent).toContain('Version 2.3.0-beta is available')
})
})
describe('Sidebar pill and mobile dot during a job', () => {
it.each([
{ name: 'downloading', status: downloading() },
{ name: 'verifying', status: verifying() },
{ name: 'ready', status: ready() },
{ name: 'available with the helper', status: startable() },
])('stay for the owner while $name', async ({ status }) => {
await mountHome('owner', status)
expect(settingsNavItem().textContent).toContain('Update')
expect(mobileDot()).not.toBeNull()
})
it('are absent for an admin while ready', async () => {
await mountHome('admin', { ...newerAvailable(), state: 'ready' })
expect(settingsNavItem().textContent).not.toContain('Update')
expect(mobileDot()).toBeNull()
expect(useUpdateStore().status).toBeNull()
})
})
@@ -0,0 +1,614 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, NetworkError, useApi, type UserRole } from '@/composables/useApi'
import type { UpdateStatus } from '@/composables/useUpdate'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import {
CHECK_PATH,
DOWNLOAD_PATH,
NOTE_ITEMS,
RUNNING_VERSION,
STATUS_PATH,
downloading,
failedWith,
findButton,
newerAvailable,
ready,
startable,
verifying,
visibleButtonLabels,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
}
})
type Outcome = UpdateStatus | Error
type Handler = () => Promise<unknown>
let requestMock: ReturnType<typeof vi.fn>
let getMeMock: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
function sequence(...outcomes: Outcome[]): Handler {
let index = 0
return () => {
const outcome = outcomes[Math.min(index, outcomes.length - 1)]!
index++
return outcome instanceof Error ? Promise.reject(outcome) : Promise.resolve(outcome)
}
}
interface MountOptions {
role: UserRole
status: Handler | UpdateStatus
download?: Handler | UpdateStatus | Error
meAfterRefresh?: UserRole
}
function asHandler(value: Handler | Outcome): Handler {
if (typeof value === 'function') {
return value
}
return sequence(value)
}
async function mountSettings(options: MountOptions) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
session.status = 'ready'
const statusHandler = asHandler(options.status)
const downloadHandler = asHandler(options.download ?? new ApiError(500))
requestMock = vi.fn((path: string) => {
if (path === '/v1/settings/') {
return Promise.resolve({ version: RUNNING_VERSION })
}
if (path === STATUS_PATH) {
return statusHandler()
}
if (path === DOWNLOAD_PATH) {
return downloadHandler()
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: options.meAfterRefresh ?? options.role,
})
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function useTimers() {
vi.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
})
}
function updatesCard() {
return document.body.querySelector('[data-testid="updates-card"]')!
}
function cardText() {
return updatesCard().textContent ?? ''
}
function cardButtons() {
return visibleButtonLabels(updatesCard())
}
function callsTo(path: string) {
return requestMock.mock.calls.filter(([called]) => called === path)
}
function progressbar() {
return updatesCard().querySelector('[role="progressbar"]')
}
function announcer() {
return updatesCard().querySelector('[data-testid="update-announcer"]')!
}
async function pressUpdate() {
findButton('Update')!.click()
await flushPromises()
}
async function tick(milliseconds: number) {
await vi.advanceTimersByTimeAsync(milliseconds)
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Settings, owner, Update available with the helper detected', () => {
it('shows the info block, collapsed notes, the one Update button and the hint', async () => {
await mountSettings({ role: 'owner', status: startable() })
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
'You have v2.2.0-beta. Update downloads the new version and checks it. Nothing changes until you restart.',
)
expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/)
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
expect(cardButtons()).toEqual(["What's new in 2.3.0-beta", 'Update'])
expect(cardText()).toContain('Takes about 2 minutes. Your site stays online.')
expect(updatesCard().querySelector('code')).toBeNull()
expect(cardText()).not.toContain('How to update')
expect(cardText()).not.toContain('For whoever manages the server')
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(0)
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('opens the notes with the release date and the plain-text list', async () => {
await mountSettings({ role: 'owner', status: startable() })
const toggle = findButton("What's new in 2.3.0-beta")!
toggle.click()
await flushPromises()
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(cardText()).toContain('Released 24 Sep 2026')
for (const item of NOTE_ITEMS) {
expect(cardText()).toContain(item)
}
})
})
describe('Settings, owner, Downloading', () => {
it('shows the card copy, the progressbar, the text and a disabled Updating button', async () => {
useTimers()
await mountSettings({ role: 'owner', status: startable(), download: downloading() })
await pressUpdate()
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(1)
expect(requestMock).toHaveBeenCalledWith(DOWNLOAD_PATH, { method: 'POST' })
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
expect(cardText()).toContain(
'You can leave this page. The download continues and your site stays online.',
)
const bar = progressbar()!
expect(bar.getAttribute('aria-label')).toBe('Downloading version 2.3.0-beta')
expect(bar.getAttribute('aria-valuenow')).toBe('42')
expect(cardText()).toContain('42% · 11 of 26 MB')
const updating = findButton('Updating…') as HTMLButtonElement
expect(updating.disabled).toBe(true)
expect(cardButtons()).toEqual(['Updating…'])
})
it('reads the status once a second while the job runs', async () => {
useTimers()
await mountSettings({
role: 'owner',
status: sequence(startable(), downloading(42)),
download: downloading(),
})
await pressUpdate()
const before = callsTo(STATUS_PATH).length
await tick(1000)
await tick(1000)
await tick(1000)
expect(callsTo(STATUS_PATH)).toHaveLength(before + 3)
})
})
describe('Settings, progress is announced at 25 percent steps only', () => {
it('changes the announcer at 25, 50 and 75 while the bar updates on every poll', async () => {
useTimers()
const percents = [3, 24, 25, 26, 49, 50, 75, 100]
await mountSettings({
role: 'owner',
status: sequence(startable(), ...percents.map((percent) => downloading(percent))),
download: downloading(0),
})
await pressUpdate()
const announced: string[] = []
const valueNow: string[] = []
for (let index = 0; index < percents.length; index++) {
await tick(1000)
announced.push(announcer().textContent!.trim())
valueNow.push(progressbar()!.getAttribute('aria-valuenow')!)
}
expect(announced).toEqual([
'Downloading version 2.3.0-beta',
'Downloading version 2.3.0-beta',
'Downloading version 2.3.0-beta, 25 percent',
'Downloading version 2.3.0-beta, 25 percent',
'Downloading version 2.3.0-beta, 25 percent',
'Downloading version 2.3.0-beta, 50 percent',
'Downloading version 2.3.0-beta, 75 percent',
'Downloading version 2.3.0-beta, 75 percent',
])
expect(valueNow).toEqual(percents.map(String))
expect(announcer().getAttribute('role')).toBe('status')
expect(updatesCard().querySelector('[aria-live="polite"]')).toBeNull()
})
})
describe('Settings, owner, Verifying', () => {
it('shows an indeterminate bar, Almost there and announces the step once', async () => {
useTimers()
await mountSettings({
role: 'owner',
status: sequence(startable(), verifying()),
download: downloading(100),
})
await pressUpdate()
await tick(1000)
expect(cardText()).toContain('Checking the download…')
expect(cardText()).toContain(
'Making sure the file is complete and safe to install. This takes a few seconds.',
)
const bar = progressbar()!
expect(bar.hasAttribute('aria-valuenow')).toBe(false)
expect(bar.getAttribute('aria-label')).toBe('Checking the download')
expect(cardText()).toContain('Almost there')
expect((findButton('Updating…') as HTMLButtonElement).disabled).toBe(true)
expect(announcer().textContent!.trim()).toBe('Checking the download')
})
})
describe('Settings, owner, Ready to install', () => {
it('shows the success block, a disabled Restart, the notes and the hint, and stops polling', async () => {
useTimers()
await mountSettings({
role: 'owner',
status: sequence(startable(), ready()),
download: downloading(100),
})
await pressUpdate()
await tick(1000)
expect(cardText()).toContain('Version 2.3.0-beta is ready to install')
expect(cardText()).toContain(
'The download is checked. Restart to install it. Your site will be unavailable for about 10 seconds.',
)
expect(cardText()).toContain('Nothing has changed yet.')
expect(cardText()).toContain(
'Not now? Leave this page. The update waits here, and the banner on Home reminds you.',
)
expect(findButton("What's new in 2.3.0-beta")!.getAttribute('aria-expanded')).toBe('false')
const restart = findButton('Restart') as HTMLButtonElement
expect(restart.disabled).toBe(true)
const readsBefore = callsTo(STATUS_PATH).length
restart.click()
await flushPromises()
await tick(3000)
expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore)
expect(requestMock.mock.calls.map(([path]) => path)).not.toContain(
'/v1/system/update/restart',
)
expect(document.body.querySelector('[role="dialog"]')).toBeNull()
})
})
describe('Settings, Download failed', () => {
function activeId() {
return document.activeElement?.id
}
it.each([
{
name: 'a failed record on a poll',
status: sequence(startable(), failedWith('download')),
download: downloading(),
},
{ name: 'a 500', status: startable(), download: new ApiError(500) },
{ name: 'a network error', status: startable(), download: new NetworkError() },
])('shows the error card and focuses the heading after $name', async (scenario) => {
useTimers()
await mountSettings({
role: 'owner',
status: scenario.status,
download: scenario.download,
})
await pressUpdate()
await tick(1000)
await flushPromises()
expect(cardText()).toContain("The download didn't finish")
expect(cardText()).toContain(
'The connection dropped before the file was complete. Nothing was changed and your site is running as before.',
)
expect(cardButtons()).toEqual(['Try again'])
expect(activeId()).toBe('updates-h')
})
it('posts the download again from Try again', async () => {
await mountSettings({
role: 'owner',
status: startable(),
download: sequence(new ApiError(500), downloading()),
})
await pressUpdate()
findButton('Try again')!.click()
await flushPromises()
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2)
expect(callsTo(CHECK_PATH)).toHaveLength(0)
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
})
})
describe('Settings, Verification failed', () => {
function assertUntrustedCard() {
expect(cardText()).toContain("We didn't use this update")
expect(cardText()).toContain(
"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.",
)
expect(cardButtons()).toEqual(['Try again'])
expect(document.activeElement?.id).not.toBe('updates-h')
expect(cardText()).not.toMatch(/signature|checksum|sha/i)
}
it('shows the warning card from a failed record', async () => {
useTimers()
await mountSettings({
role: 'owner',
status: sequence(startable(), failedWith('verification')),
download: downloading(),
})
await pressUpdate()
await tick(1000)
assertUntrustedCard()
})
it('shows the warning card after a 422', async () => {
await mountSettings({ role: 'owner', status: startable(), download: new ApiError(422) })
await pressUpdate()
assertUntrustedCard()
})
})
describe('Settings, 409 already running', () => {
it('toasts and shows the other run while it downloads', async () => {
await mountSettings({
role: 'owner',
status: sequence(startable(), downloading(42)),
download: new ApiError(409),
})
await pressUpdate()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
color: 'info',
title: 'An update is already running',
description: 'Another owner started it. Progress shows here.',
}),
)
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
})
it('shows Ready to install without a toast when the reload answers ready', async () => {
await mountSettings({
role: 'owner',
status: sequence(startable(), ready()),
download: new ApiError(409),
})
await pressUpdate()
expect(toastAdd).not.toHaveBeenCalled()
expect(cardText()).toContain('Version 2.3.0-beta is ready to install')
})
it('shows the Not prepared card without a toast when the reload answers can_apply false', async () => {
await mountSettings({
role: 'owner',
status: sequence(startable(), newerAvailable()),
download: new ApiError(409),
})
await pressUpdate()
expect(toastAdd).not.toHaveBeenCalled()
expect(cardText()).toContain("This site can't update itself from the browser yet.")
})
})
describe('Settings, 400 nothing to update', () => {
it('toasts and reloads the status once', async () => {
await mountSettings({
role: 'owner',
status: startable(),
download: new ApiError(400),
})
const readsBefore = callsTo(STATUS_PATH).length
await pressUpdate()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
color: 'info',
title: 'Nothing to update',
description: 'You already have the latest version.',
}),
)
expect(callsTo(STATUS_PATH)).toHaveLength(readsBefore + 1)
})
})
describe('Settings, 403 role changed on Update', () => {
it('toasts, reloads and renders the admin view', async () => {
await mountSettings({
role: 'owner',
meAfterRefresh: 'admin',
status: sequence(startable(), newerAvailable()),
download: new ApiError(403),
})
await pressUpdate()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
color: 'error',
title: 'Only the owner can do this',
description: 'Ask the owner of this site to update RenewCMS.',
}),
)
expect(getMeMock).toHaveBeenCalled()
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardButtons()).toEqual([])
})
})
describe('Settings, 429 on Update', () => {
it('counts down through useRetryAfter and posts the download again at zero', async () => {
useTimers()
await mountSettings({
role: 'owner',
status: startable(),
download: sequence(new ApiError(429, 42), downloading()),
})
await pressUpdate()
expect(cardText()).toContain('Too many tries')
expect(cardText()).toContain('Wait a moment before trying again.')
expect((findButton('Try again in 0:42') as HTMLButtonElement).disabled).toBe(true)
await tick(1000)
expect(findButton('Try again in 0:41')).toBeTruthy()
await tick(41000)
const again = findButton('Try again') as HTMLButtonElement
expect(again.disabled).toBe(false)
again.click()
await flushPromises()
expect(callsTo(DOWNLOAD_PATH)).toHaveLength(2)
expect(callsTo(CHECK_PATH)).toHaveLength(0)
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
})
})
describe('Settings, reload keeps progress', () => {
it('stops polling on leave and resumes it on the next open', async () => {
useTimers()
await mountSettings({ role: 'owner', status: downloading(42) })
await tick(1000)
const beforeLeaving = callsTo(STATUS_PATH).length
expect(beforeLeaving).toBeGreaterThan(1)
wrapper!.unmount()
wrapper = null
await tick(5000)
expect(callsTo(STATUS_PATH)).toHaveLength(beforeLeaving)
document.body.innerHTML = ''
await mountSettings({ role: 'owner', status: downloading(42) })
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
const afterOpening = callsTo(STATUS_PATH).length
await tick(2000)
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening + 2)
})
it.each([
{ name: 'idle', status: startable() },
{ name: 'ready', status: ready() },
{ name: 'failed', status: failedWith('download') },
])('does not poll while the state is $name', async ({ status }) => {
useTimers()
await mountSettings({ role: 'owner', status })
const afterOpening = callsTo(STATUS_PATH).length
await tick(5000)
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
})
})
describe('Settings, a poll that fails keeps the card', () => {
it.each([
{ name: 'a 500', failure: new ApiError(500) },
{ name: 'a network error', failure: new NetworkError() },
])('keeps the last progress and polls again after $name', async ({ failure }) => {
useTimers()
await mountSettings({
role: 'owner',
status: sequence(downloading(42), failure, failure, downloading(60)),
})
await tick(1000)
expect(cardText()).toContain('Downloading version 2.3.0-beta…')
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
expect(cardText()).not.toContain("The download didn't finish")
await tick(1000)
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('42')
await tick(1000)
expect(progressbar()!.getAttribute('aria-valuenow')).toBe('60')
})
})
describe('Settings, admin view during a job', () => {
it('shows the admin copy with no button, progressbar or poll', async () => {
useTimers()
await mountSettings({
role: 'admin',
status: { ...downloading(42), can_apply: false },
})
const afterOpening = callsTo(STATUS_PATH).length
await tick(5000)
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
)
expect(cardButtons()).toEqual([])
expect(progressbar()).toBeNull()
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
})
})
+30
View File
@@ -13,6 +13,10 @@ export const NOTE_ITEMS = [
export const STATUS_PATH = '/v1/system/update'
export const CHECK_PATH = '/v1/system/update/check'
export const DOWNLOAD_PATH = '/v1/system/update/download'
export const BOARD_TOTAL_BYTES = 27262976
export const BOARD_DONE_BYTES = 11534336
export function hoursAgo(hours: number) {
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
@@ -64,3 +68,29 @@ export function findButton(label: string, root: ParentNode = document.body) {
(button) => button.textContent?.trim() === label,
)
}
export function startable(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return newerAvailable({ can_apply: true, ...overrides })
}
export function downloading(percent = 42): UpdateStatus {
const downloaded =
percent === 42 ? BOARD_DONE_BYTES : Math.floor((BOARD_TOTAL_BYTES * percent) / 100)
return startable({
state: 'downloading',
started_at: '2026-09-30T09:51:00Z',
progress: { percent, downloaded_bytes: downloaded, total_bytes: BOARD_TOTAL_BYTES },
})
}
export function verifying(): UpdateStatus {
return startable({ state: 'verifying', started_at: '2026-09-30T09:51:00Z' })
}
export function ready(): UpdateStatus {
return startable({ state: 'ready', started_at: '2026-09-30T09:51:00Z' })
}
export function failedWith(error: 'download' | 'verification'): UpdateStatus {
return startable({ state: 'failed', error, started_at: '2026-09-30T09:51:00Z' })
}
+60 -9
View File
@@ -208,6 +208,44 @@ function updateAvailable() {
})
}
const updateBoardNotes = {
version: updateLatestTag,
released_at: updateReleasedAt,
items: [
'Images upload faster, and large images no longer stall.',
'Fixed a problem where a draft could show an old title after saving.',
'Small improvements to the Users page.',
],
}
const updateBoardTotalBytes = 27262976
const updateBoardDoneBytes = 11534336
const updateJobStartedAt = '2026-09-30T09:51:00Z'
function updateStartable(overrides: Record<string, unknown> = {}) {
return updateStatusBody({
latest: updateLatestTag,
available: true,
notes: updateBoardNotes,
can_apply: true,
...overrides,
})
}
function updateJob(state: string, overrides: Record<string, unknown> = {}) {
return updateStartable({ state, started_at: updateJobStartedAt, ...overrides })
}
function updateDownloading() {
return updateJob('downloading', {
progress: {
percent: 42,
downloaded_bytes: updateBoardDoneBytes,
total_bytes: updateBoardTotalBytes,
},
})
}
const updateCheckHangs: ApiMock = { status: 200, hang: true }
function updateApi(
@@ -2046,19 +2084,25 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
},
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
'Update available': { path: '/settings', skip: 'pending' },
'Update available, notes open': { path: '/settings', skip: 'pending' },
Downloading: { path: '/settings', skip: 'pending' },
Verifying: { path: '/settings', skip: 'pending' },
'Ready to install': { path: '/settings', skip: 'pending' },
'Update available': { path: '/settings', skip: 'clock-sensitive' },
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) },
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) },
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
'Restart: confirm': { path: '/settings', skip: 'pending' },
'Restart: request failed': { path: '/settings', skip: 'pending' },
'Restarting to install': { path: '/settings', skip: 'pending' },
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
'Update done': { path: '/settings', skip: 'pending' },
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
'Download failed': { path: '/settings', skip: 'pending' },
'Verification failed': { path: '/settings', skip: 'pending' },
'Download failed': {
path: '/settings',
api: updateApi('owner', updateJob('failed', { error: 'download' })),
},
'Verification failed': {
path: '/settings',
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
},
'Too many requests (429)': {
path: '/settings',
api: updateApi('owner', updateNeverChecked(), {
@@ -2104,7 +2148,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
},
},
},
'Update already running (409)': { path: '/settings', skip: 'pending' },
'Update already running (409)': { path: '/settings', skip: 'clock-sensitive' },
},
integration: {
Default: {
@@ -2378,7 +2422,14 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'GET /v1/system/update': { status: 200, body: updateAvailable() },
},
},
'Update ready to install (owner)': { path: '/home', skip: 'pending' },
'Update ready to install (owner)': {
path: '/home',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateJob('ready') },
},
},
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },