feat: update button (download, verify, stage)
This commit is contained in:
1 parent
834493a4f5
commit
70460e4170
41 files changed
+5345
-97
No files matched your search
+63
-15
@@ -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,21 +59,32 @@ type UseCases struct {
|
||||
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||
}
|
||||
|
||||
type Repositories struct {
|
||||
ArticleRepo domainArticle.Repository
|
||||
UserRepo domainUser.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
InviteRepo domainInvite.Repository
|
||||
MailRepo domainMail.Repository
|
||||
ReleaseFeed domainUpdate.ReleaseFeed
|
||||
UpdateStore domainUpdate.StatusStore
|
||||
ArticleRepo domainArticle.Repository
|
||||
UserRepo domainUser.Repository
|
||||
ImageRepo domainImage.Repository
|
||||
InviteRepo domainInvite.Repository
|
||||
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,15 +115,30 @@ 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),
|
||||
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||
InviteRepo: persistence.NewInviteRepository(db),
|
||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||
UpdateStore: update.NewJSONStatusStore(filepath.Join(filepath.Dir(settings.DBFile), updateStatusFileName)),
|
||||
ArticleRepo: persistence.NewArticleRepository(db),
|
||||
UserRepo: persistence.NewUserRepository(db),
|
||||
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||
InviteRepo: persistence.NewInviteRepository(db),
|
||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
}
|
||||
@@ -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."
|
||||
+10
-3
@@ -132,14 +132,16 @@ func NewSettingsRouter(container *UseCases) http.Handler {
|
||||
}
|
||||
|
||||
const (
|
||||
updateCheckMaxAttempts = 5
|
||||
updateCheckWindow = time.Minute
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -24,11 +24,18 @@ type GiteaReleaseFeed struct {
|
||||
var _ domain.ReleaseFeed = &GiteaReleaseFeed{}
|
||||
|
||||
type giteaRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
Draft bool `json:"draft"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
TagName string `json:"tag_name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
+18
-11
@@ -20,11 +20,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
updateOwnerId = 1
|
||||
updateAdminId = 2
|
||||
updateUnverifiedId = 3
|
||||
updateSecondOwner = 4
|
||||
updateRunning = "v2.2.0-beta"
|
||||
updateOwnerId = 1
|
||||
updateAdminId = 2
|
||||
updateUnverifiedId = 3
|
||||
updateSecondOwner = 4
|
||||
updateUnverifiedOwner = 5
|
||||
updateRunning = "v2.2.0-beta"
|
||||
)
|
||||
|
||||
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "latest", "notes", "progress", "release_url", "started_at", "state"}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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()
|
||||
await update.load()
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user