feat: update button (download, verify, stage)
This commit is contained in:
1 parent
834493a4f5
commit
70460e4170
41 files changed
+5316
-68
No files matched your search
@@ -14,9 +14,12 @@ import (
|
|||||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
"RenewCMS/internal/infrastructure/update"
|
"RenewCMS/internal/infrastructure/update"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
|
"crypto/ed25519"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
@@ -56,6 +59,7 @@ type UseCases struct {
|
|||||||
GetSettingsUseCase *useCases.GetSettingsUseCase
|
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||||
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
||||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||||
|
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repositories struct {
|
type Repositories struct {
|
||||||
@@ -66,11 +70,21 @@ type Repositories struct {
|
|||||||
MailRepo domainMail.Repository
|
MailRepo domainMail.Repository
|
||||||
ReleaseFeed domainUpdate.ReleaseFeed
|
ReleaseFeed domainUpdate.ReleaseFeed
|
||||||
UpdateStore domainUpdate.StatusStore
|
UpdateStore domainUpdate.StatusStore
|
||||||
|
UpdateJobs domainUpdate.JobStore
|
||||||
|
UpdateHelper domainUpdate.HelperDetector
|
||||||
|
AssetFetcher domainUpdate.AssetFetcher
|
||||||
|
SignatureVerifier domainUpdate.SignatureVerifier
|
||||||
|
StagedRunner domainUpdate.StagedBinaryRunner
|
||||||
|
DatabaseBackup domainUpdate.DatabaseBackup
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sqliteBusyTimeoutMs = 5000
|
sqliteBusyTimeoutMs = 5000
|
||||||
updateStatusFileName = "update-status.json"
|
updateStatusFileName = "update-status.json"
|
||||||
|
updateJobFileName = "update-job.json"
|
||||||
|
updateHelperFileName = "update-helper.json"
|
||||||
|
updatesDirName = "updates"
|
||||||
|
backupsDirName = "backups"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SQLiteDSN(dbName string) string {
|
func SQLiteDSN(dbName string) string {
|
||||||
@@ -101,7 +115,16 @@ func getDb(dbName string, uploadDir string) *gorm.DB {
|
|||||||
return 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 {
|
func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
||||||
|
dataDir := filepath.Dir(settings.DBFile)
|
||||||
return &Repositories{
|
return &Repositories{
|
||||||
ArticleRepo: persistence.NewArticleRepository(db),
|
ArticleRepo: persistence.NewArticleRepository(db),
|
||||||
UserRepo: persistence.NewUserRepository(db),
|
UserRepo: persistence.NewUserRepository(db),
|
||||||
@@ -109,7 +132,13 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
|
|||||||
InviteRepo: persistence.NewInviteRepository(db),
|
InviteRepo: persistence.NewInviteRepository(db),
|
||||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
||||||
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL),
|
||||||
UpdateStore: update.NewJSONStatusStore(filepath.Join(filepath.Dir(settings.DBFile), updateStatusFileName)),
|
UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)),
|
||||||
|
UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)),
|
||||||
|
UpdateHelper: update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)),
|
||||||
|
AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL),
|
||||||
|
SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()),
|
||||||
|
StagedRunner: update.NewExecStagedBinaryRunner(),
|
||||||
|
DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,11 +175,30 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases {
|
|||||||
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
|
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
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),
|
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) {
|
func InitContainer(settings config.Settings) (*UseCases, func() error) {
|
||||||
db := getDb(settings.DBFile, settings.UploadDir)
|
db := getDb(settings.DBFile, settings.UploadDir)
|
||||||
repos := getRepositories(db, settings)
|
repos := getRepositories(db, settings)
|
||||||
|
|||||||
@@ -1,39 +1,100 @@
|
|||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/claims"
|
||||||
domain "RenewCMS/internal/domain/update"
|
domain "RenewCMS/internal/domain/update"
|
||||||
|
"RenewCMS/internal/domain/user"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
||||||
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||||
|
StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase
|
||||||
|
GetUserUseCase *useCases.GetUserUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase) *Handler {
|
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, getUser *useCases.GetUserUseCase) *Handler {
|
||||||
return &Handler{GetUpdateStatusUseCase: getStatus, CheckForUpdateUseCase: checkForUpdate}
|
return &Handler{
|
||||||
|
GetUpdateStatusUseCase: getStatus,
|
||||||
|
CheckForUpdateUseCase: checkForUpdate,
|
||||||
|
StartUpdateDownloadUseCase: startDownload,
|
||||||
|
GetUserUseCase: getUser,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetStatus(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
writeStatus(w, h.GetUpdateStatusUseCase.GetStatus())
|
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) {
|
func (h *Handler) Check(w http.ResponseWriter, r *http.Request) {
|
||||||
status, err := h.CheckForUpdateUseCase.Check()
|
if _, err := h.CheckForUpdateUseCase.Check(); err != nil {
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
body, err := json.Marshal(status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.WriteHeader(code)
|
||||||
_, _ = w.Write(body)
|
_, _ = w.Write(body)
|
||||||
}
|
}
|
||||||
@@ -29,3 +29,7 @@ const MediaQueryErrorMessage = "The media query is invalid."
|
|||||||
const MediaFilenameErrorMessage = "Enter a filename."
|
const MediaFilenameErrorMessage = "Enter a filename."
|
||||||
const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again."
|
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 MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it."
|
||||||
|
const UpdateNotPreparedErrorMessage = "This server cannot update itself yet."
|
||||||
|
const UpdateAlreadyRunningErrorMessage = "An update is already running or waiting to be installed."
|
||||||
|
const NothingToUpdateErrorMessage = "There is nothing to update."
|
||||||
|
const UpdateRefusedErrorMessage = "This update cannot be downloaded."
|
||||||
+8
-1
@@ -134,12 +134,14 @@ func NewSettingsRouter(container *UseCases) http.Handler {
|
|||||||
const (
|
const (
|
||||||
updateCheckMaxAttempts = 5
|
updateCheckMaxAttempts = 5
|
||||||
updateCheckWindow = time.Minute
|
updateCheckWindow = time.Minute
|
||||||
|
updateDownloadMaxAttempts = 5
|
||||||
|
updateDownloadWindow = time.Minute
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewSystemRouter(container *UseCases) http.Handler {
|
func NewSystemRouter(container *UseCases) http.Handler {
|
||||||
r := chi.NewRouter()
|
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.Verifier(container.Token))
|
||||||
r.Use(jwtauth.Authenticator(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.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
|
||||||
r.Post("/update/check", updateHandler.Check)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,13 @@ var envDefaults = map[string]string{
|
|||||||
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const versionFlag = "--version"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) == 2 && os.Args[1] == versionFlag {
|
||||||
|
fmt.Println(version)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
log.Fatal(err)
|
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.
|
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
|
## 2. Lay out the server
|
||||||
|
|
||||||
The following is a suggested layout for Linux:
|
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"`
|
Items []string `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Asset struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
type Release struct {
|
type Release struct {
|
||||||
Tag string
|
Tag string
|
||||||
URL string
|
URL string
|
||||||
PublishedAt time.Time
|
PublishedAt time.Time
|
||||||
Notes ReleaseNotes
|
Notes ReleaseNotes
|
||||||
|
Assets []Asset
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckResult struct {
|
type CheckResult struct {
|
||||||
Latest string `json:"latest"`
|
Latest string `json:"latest"`
|
||||||
ReleaseURL string `json:"release_url"`
|
ReleaseURL string `json:"release_url"`
|
||||||
Notes ReleaseNotes `json:"notes"`
|
Notes ReleaseNotes `json:"notes"`
|
||||||
|
Assets []Asset `json:"assets"`
|
||||||
CheckedAt time.Time `json:"checked_at"`
|
CheckedAt time.Time `json:"checked_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +66,7 @@ func NewResult(release Release, checkedAt time.Time) CheckResult {
|
|||||||
Latest: release.Tag,
|
Latest: release.Tag,
|
||||||
ReleaseURL: release.URL,
|
ReleaseURL: release.URL,
|
||||||
Notes: release.Notes,
|
Notes: release.Notes,
|
||||||
|
Assets: release.Assets,
|
||||||
CheckedAt: checkedAt.UTC(),
|
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
|
||||||
|
}
|
||||||
@@ -29,6 +29,13 @@ type giteaRelease struct {
|
|||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Draft bool `json:"draft"`
|
Draft bool `json:"draft"`
|
||||||
PublishedAt time.Time `json:"published_at"`
|
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 {
|
func NewGiteaReleaseFeed(baseURL string) *GiteaReleaseFeed {
|
||||||
@@ -73,7 +80,16 @@ func toReleases(raw []giteaRelease) []domain.Release {
|
|||||||
URL: item.HTMLURL,
|
URL: item.HTMLURL,
|
||||||
PublishedAt: item.PublishedAt,
|
PublishedAt: item.PublishedAt,
|
||||||
Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body),
|
Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body),
|
||||||
|
Assets: toAssets(item.Assets),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return releases
|
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 {
|
type GetUpdateStatusUseCase struct {
|
||||||
store update.StatusStore
|
store update.StatusStore
|
||||||
|
jobs update.JobStore
|
||||||
|
helper update.HelperDetector
|
||||||
currentVersion string
|
currentVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGetUpdateStatusUseCase(store update.StatusStore, currentVersion string) *GetUpdateStatusUseCase {
|
func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, currentVersion string) *GetUpdateStatusUseCase {
|
||||||
return &GetUpdateStatusUseCase{store: store, currentVersion: currentVersion}
|
return &GetUpdateStatusUseCase{store: store, jobs: jobs, helper: helper, currentVersion: currentVersion}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GetUpdateStatusUseCase) GetStatus() update.UpdateStatus {
|
func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus {
|
||||||
result, found := g.store.Load()
|
return buildUpdateStatus(g.store, g.jobs, g.currentVersion, isOwner && g.helper.Installed())
|
||||||
if !found {
|
}
|
||||||
return update.NewStatus(g.currentVersion, nil)
|
|
||||||
|
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
@@ -24,6 +24,7 @@ const (
|
|||||||
updateAdminId = 2
|
updateAdminId = 2
|
||||||
updateUnverifiedId = 3
|
updateUnverifiedId = 3
|
||||||
updateSecondOwner = 4
|
updateSecondOwner = 4
|
||||||
|
updateUnverifiedOwner = 5
|
||||||
updateRunning = "v2.2.0-beta"
|
updateRunning = "v2.2.0-beta"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,14 +37,20 @@ type updateHarness struct {
|
|||||||
store *fakeUpdateStore
|
store *fakeUpdateStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUpdateHarness(t *testing.T, running string) *updateHarness {
|
func newUpdateUserRepo() *mockUserRepo {
|
||||||
t.Helper()
|
|
||||||
validator.InitValidator()
|
|
||||||
users := newMockUserRepo()
|
users := newMockUserRepo()
|
||||||
users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
|
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[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin}
|
||||||
users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, 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[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{
|
feed := &fakeReleaseFeed{release: update.Release{
|
||||||
Tag: "v2.3.0-beta",
|
Tag: "v2.3.0-beta",
|
||||||
URL: "https://git.example/fsylvain/RenewCMS/releases/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{
|
container := &api.UseCases{
|
||||||
Token: token,
|
Token: token,
|
||||||
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
GetUserUseCase: useCases.NewGetUserUseCase(users),
|
||||||
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, running),
|
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
|
||||||
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
|
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
|
||||||
}
|
}
|
||||||
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
|
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)
|
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 {
|
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)
|
t.Errorf("%s status = %d", path, rec.Code)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -154,7 +154,7 @@ func TestReleaseNotesStripInlineMarkdown(t *testing.T) {
|
|||||||
|
|
||||||
func TestGetUpdateStatusUseCase(t *testing.T) {
|
func TestGetUpdateStatusUseCase(t *testing.T) {
|
||||||
t.Run("never checked", func(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 {
|
if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available {
|
||||||
t.Errorf("status = %+v", status)
|
t.Errorf("status = %+v", status)
|
||||||
@@ -170,7 +170,7 @@ func TestGetUpdateStatusUseCase(t *testing.T) {
|
|||||||
t.Run("stored result", func(t *testing.T) {
|
t.Run("stored result", func(t *testing.T) {
|
||||||
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
|
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 {
|
if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle {
|
||||||
t.Errorf("status = %+v", status)
|
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) {
|
t.Run("dev build never reports an update", func(t *testing.T) {
|
||||||
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
|
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")
|
t.Error("a dev build must not report an update")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
export type UpdateBannerState = 'available'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
export type UpdateBannerState = 'available' | 'ready'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
state: UpdateBannerState
|
state: UpdateBannerState
|
||||||
version: string
|
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<{
|
const emit = defineEmits<{
|
||||||
'go-to-settings': []
|
'go-to-settings': []
|
||||||
dismiss: []
|
dismiss: []
|
||||||
@@ -19,9 +37,9 @@ const emit = defineEmits<{
|
|||||||
color="info"
|
color="info"
|
||||||
variant="soft"
|
variant="soft"
|
||||||
orientation="horizontal"
|
orientation="horizontal"
|
||||||
icon="i-lucide-circle-arrow-up"
|
:icon="copy.icon"
|
||||||
:title="`Version ${version} is available`"
|
:title="copy.title(version)"
|
||||||
description="Update it from Settings. It takes about 2 minutes and the site stays online."
|
:description="copy.description"
|
||||||
:ui="{
|
: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',
|
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',
|
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
|
title: string
|
||||||
text: string
|
text: string
|
||||||
meta?: string
|
meta?: string
|
||||||
|
live?: 'polite' | 'off'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const toneClasses = computed(() => TONE_CLASSES[props.tone])
|
const toneClasses = computed(() => TONE_CLASSES[props.tone])
|
||||||
@@ -24,7 +25,7 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<span class="flex pt-0.5">
|
||||||
<UIcon
|
<UIcon
|
||||||
:name="icon"
|
: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">
|
<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-base font-semibold text-highlighted">{{ title }}</span>
|
||||||
<span class="text-sm text-pretty text-toned">{{ text }}</span>
|
<span class="text-sm text-pretty text-toned">{{ text }}</span>
|
||||||
|
<slot />
|
||||||
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
|
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import ReleaseNotes from '@/components/ReleaseNotes.vue'
|
import ReleaseNotes from '@/components/ReleaseNotes.vue'
|
||||||
import ServerCommand from '@/components/ServerCommand.vue'
|
import ServerCommand from '@/components/ServerCommand.vue'
|
||||||
|
import UpdateProgress from '@/components/UpdateProgress.vue'
|
||||||
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
|
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
|
||||||
import type { UpdateStatus } from '@/composables/useUpdate'
|
import type { UpdateStatus } from '@/composables/useUpdate'
|
||||||
import type { UserRole } from '@/composables/useApi'
|
import type { UserRole } from '@/composables/useApi'
|
||||||
import type { UpdateCheckPhase } from '@/stores/update'
|
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
|
||||||
import {
|
import {
|
||||||
ENABLE_BROWSER_UPDATES_COMMAND,
|
ENABLE_BROWSER_UPDATES_COMMAND,
|
||||||
|
VERIFYING_ANNOUNCEMENT,
|
||||||
displayVersion,
|
displayVersion,
|
||||||
|
downloadAnnouncement,
|
||||||
|
formatDownloadProgress,
|
||||||
formatLastChecked,
|
formatLastChecked,
|
||||||
formatReleased,
|
formatReleased,
|
||||||
} from '@/lib/updateFormat'
|
} from '@/lib/updateFormat'
|
||||||
|
|
||||||
|
type ApplyView =
|
||||||
|
| 'available'
|
||||||
|
| 'downloading'
|
||||||
|
| 'verifying'
|
||||||
|
| 'ready'
|
||||||
|
| 'download-failed'
|
||||||
|
| 'verification-failed'
|
||||||
|
| 'limited'
|
||||||
|
|
||||||
|
type ButtonAction = 'check' | 'update'
|
||||||
|
|
||||||
interface Block {
|
interface Block {
|
||||||
tone: UpdateTone
|
tone: UpdateTone
|
||||||
icon: string
|
icon: string
|
||||||
@@ -26,26 +41,208 @@ interface ActionButton {
|
|||||||
icon: string
|
icon: string
|
||||||
primary: boolean
|
primary: boolean
|
||||||
disabled: 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<{
|
const props = defineProps<{
|
||||||
status: UpdateStatus
|
status: UpdateStatus
|
||||||
role: UserRole | undefined
|
role: UserRole | undefined
|
||||||
phase: UpdateCheckPhase
|
phase: UpdateCheckPhase
|
||||||
|
applyPhase: UpdateApplyPhase
|
||||||
retryLabel: string
|
retryLabel: string
|
||||||
retryActive: boolean
|
retryActive: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
check: []
|
check: []
|
||||||
|
update: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const heading = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
const isOwner = computed(() => props.role === 'owner')
|
const isOwner = computed(() => props.role === 'owner')
|
||||||
const newVersion = computed(() => displayVersion(props.status.latest))
|
const newVersion = computed(() => displayVersion(props.status.latest))
|
||||||
const lastChecked = computed(() =>
|
const lastChecked = computed(() =>
|
||||||
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
|
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>(() => {
|
const block = computed<Block>(() => {
|
||||||
if (isOwner.value && props.phase === 'checking') {
|
if (isOwner.value && props.phase === 'checking') {
|
||||||
return {
|
return {
|
||||||
@@ -72,6 +269,9 @@ const block = computed<Block>(() => {
|
|||||||
meta: lastChecked.value,
|
meta: lastChecked.value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (applyView.value) {
|
||||||
|
return applyBlocks.value[applyView.value]
|
||||||
|
}
|
||||||
if (props.status.available && isOwner.value) {
|
if (props.status.available && isOwner.value) {
|
||||||
return {
|
return {
|
||||||
tone: 'info',
|
tone: 'info',
|
||||||
@@ -100,7 +300,11 @@ const block = computed<Block>(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const showServerGuidance = computed(
|
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>(() => {
|
const actionButton = computed<ActionButton | null>(() => {
|
||||||
@@ -113,10 +317,17 @@ const actionButton = computed<ActionButton | null>(() => {
|
|||||||
icon: 'i-lucide-loader-circle',
|
icon: 'i-lucide-loader-circle',
|
||||||
primary: false,
|
primary: false,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
|
action: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (props.phase === 'failed') {
|
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') {
|
if (props.phase === 'limited') {
|
||||||
return props.retryActive
|
return props.retryActive
|
||||||
@@ -125,8 +336,18 @@ const actionButton = computed<ActionButton | null>(() => {
|
|||||||
icon: 'i-lucide-clock',
|
icon: 'i-lucide-clock',
|
||||||
primary: false,
|
primary: false,
|
||||||
disabled: true,
|
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) {
|
if (props.status.available) {
|
||||||
return null
|
return null
|
||||||
@@ -136,12 +357,28 @@ const actionButton = computed<ActionButton | null>(() => {
|
|||||||
icon: 'i-lucide-refresh-cw',
|
icon: 'i-lucide-refresh-cw',
|
||||||
primary: false,
|
primary: false,
|
||||||
disabled: 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 notes = computed(() => {
|
||||||
const items = props.status.notes?.items ?? []
|
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 null
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -159,6 +396,7 @@ const notes = computed(() => {
|
|||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
id="updates-h"
|
id="updates-h"
|
||||||
|
ref="heading"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
|
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"
|
:title="block.title"
|
||||||
:text="block.text"
|
:text="block.text"
|
||||||
:meta="block.meta"
|
: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" />
|
<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'
|
: 'border border-accented bg-default text-highlighted ring-0'
|
||||||
"
|
"
|
||||||
:ui="{ leadingIcon: 'size-4' }"
|
:ui="{ leadingIcon: 'size-4' }"
|
||||||
@click="emit('check')"
|
@click="onAction"
|
||||||
>
|
>
|
||||||
{{ actionButton.label }}
|
{{ actionButton.label }}
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<span v-if="hint" class="text-xs/4.5 text-pretty text-muted">{{ hint }}</span>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
v-if="showServerGuidance && status.release_url"
|
v-if="showServerGuidance && status.release_url"
|
||||||
:href="status.release_url"
|
:href="status.release_url"
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export interface UpdateNotes {
|
|||||||
items: string[]
|
items: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateProgress {
|
||||||
|
percent: number
|
||||||
|
downloaded_bytes: number
|
||||||
|
total_bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateErrorKind = 'download' | 'verification'
|
||||||
|
|
||||||
export interface UpdateStatus {
|
export interface UpdateStatus {
|
||||||
current: string
|
current: string
|
||||||
latest: string
|
latest: string
|
||||||
@@ -24,8 +32,8 @@ export interface UpdateStatus {
|
|||||||
checked_at: string | null
|
checked_at: string | null
|
||||||
can_apply: boolean
|
can_apply: boolean
|
||||||
state: UpdateState
|
state: UpdateState
|
||||||
progress: unknown
|
progress: UpdateProgress | null
|
||||||
error: string | null
|
error: UpdateErrorKind | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,5 +48,9 @@ export function useUpdate() {
|
|||||||
return request<UpdateStatus>('/v1/system/update/check', { method: 'POST' })
|
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 {
|
import {
|
||||||
AUTO_CHECK_INTERVAL_MS,
|
AUTO_CHECK_INTERVAL_MS,
|
||||||
displayVersion,
|
displayVersion,
|
||||||
|
downloadAnnouncement,
|
||||||
|
formatDownloadProgress,
|
||||||
formatLastChecked,
|
formatLastChecked,
|
||||||
formatReleased,
|
formatReleased,
|
||||||
isCheckDue,
|
isCheckDue,
|
||||||
@@ -64,3 +66,29 @@ describe('formatReleased', () => {
|
|||||||
expect(formatReleased('2026-09-24T08:00:00Z')).toBe('Released 24 Sep 2026')
|
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) {
|
export function formatReleased(iso: string) {
|
||||||
return `Released ${formatAbsoluteDateFromIso(iso)}`
|
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 { ApiError, NetworkError, useApi } from '@/composables/useApi'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
|
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) => {
|
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||||
@@ -136,3 +145,130 @@ describe('banner dismissal', () => {
|
|||||||
expect(store.bannerVisible).toBe(false)
|
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 UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||||
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
|
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
|
||||||
export type UpdateCheckResult = 'updated' | 'failed' | 'limited' | 'forbidden' | 'busy'
|
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'
|
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
|
||||||
const DEFAULT_RETRY_SECONDS = 60
|
const DEFAULT_RETRY_SECONDS = 60
|
||||||
@@ -32,6 +42,7 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
const status = ref<UpdateStatus | null>(null)
|
const status = ref<UpdateStatus | null>(null)
|
||||||
const loadState = ref<UpdateLoadState>('idle')
|
const loadState = ref<UpdateLoadState>('idle')
|
||||||
const phase = ref<UpdateCheckPhase>('idle')
|
const phase = ref<UpdateCheckPhase>('idle')
|
||||||
|
const applyPhase = ref<UpdateApplyPhase>('idle')
|
||||||
const retryAfter = ref(0)
|
const retryAfter = ref(0)
|
||||||
const dismissedVersion = ref<string | null>(readDismissedVersion())
|
const dismissedVersion = ref<string | null>(readDismissedVersion())
|
||||||
let pendingLoad: Promise<void> | null = null
|
let pendingLoad: Promise<void> | null = null
|
||||||
@@ -42,6 +53,13 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
const bannerVisible = computed(
|
const bannerVisible = computed(
|
||||||
() => showCue.value && status.value?.latest !== dismissedVersion.value,
|
() => 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() {
|
function load() {
|
||||||
if (pendingLoad) {
|
if (pendingLoad) {
|
||||||
@@ -49,6 +67,9 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
}
|
}
|
||||||
const { getUpdateStatus } = useUpdate()
|
const { getUpdateStatus } = useUpdate()
|
||||||
loadState.value = 'loading'
|
loadState.value = 'loading'
|
||||||
|
if (applyPhase.value !== 'starting') {
|
||||||
|
applyPhase.value = 'idle'
|
||||||
|
}
|
||||||
pendingLoad = Promise.resolve()
|
pendingLoad = Promise.resolve()
|
||||||
.then(getUpdateStatus)
|
.then(getUpdateStatus)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
@@ -64,6 +85,60 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
return pendingLoad
|
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> {
|
async function check(): Promise<UpdateCheckResult> {
|
||||||
if (phase.value === 'checking') {
|
if (phase.value === 'checking') {
|
||||||
return 'busy'
|
return 'busy'
|
||||||
@@ -101,12 +176,17 @@ export const useUpdateStore = defineStore('update', () => {
|
|||||||
status,
|
status,
|
||||||
loadState,
|
loadState,
|
||||||
phase,
|
phase,
|
||||||
|
applyPhase,
|
||||||
retryAfter,
|
retryAfter,
|
||||||
isOwner,
|
isOwner,
|
||||||
available,
|
available,
|
||||||
showCue,
|
showCue,
|
||||||
bannerVisible,
|
bannerVisible,
|
||||||
|
jobRunning,
|
||||||
|
readyToInstall,
|
||||||
load,
|
load,
|
||||||
|
poll,
|
||||||
|
startDownload,
|
||||||
check,
|
check,
|
||||||
dismissBanner,
|
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(
|
watch(
|
||||||
() => update.isOwner,
|
() => update.isOwner,
|
||||||
(isOwner) => {
|
(isOwner) => {
|
||||||
@@ -160,11 +177,11 @@ onMounted(loadArticles)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UpdateBanner
|
<UpdateBanner
|
||||||
v-if="update.bannerVisible && update.status"
|
v-if="bannerState && update.status"
|
||||||
state="available"
|
:state="bannerState"
|
||||||
:version="displayVersion(update.status.latest)"
|
:version="displayVersion(update.status.latest)"
|
||||||
@go-to-settings="router.push('/settings')"
|
@go-to-settings="router.push('/settings')"
|
||||||
@dismiss="update.dismissBanner"
|
@dismiss="dismissBanner"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
|
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useSessionStore } from '@/stores/session'
|
|||||||
import { useUpdateStore } from '@/stores/update'
|
import { useUpdateStore } from '@/stores/update'
|
||||||
|
|
||||||
const SKELETON_DELAY_MS = 150
|
const SKELETON_DELAY_MS = 150
|
||||||
|
const POLL_INTERVAL_MS = 1000
|
||||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||||
|
|
||||||
type LoadState = 'loading' | 'ready' | 'error'
|
type LoadState = 'loading' | 'ready' | 'error'
|
||||||
@@ -85,19 +86,60 @@ const updatesStatus = computed(() => {
|
|||||||
|
|
||||||
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
|
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
|
||||||
|
|
||||||
async function onCheck() {
|
async function reloadAfterRoleChange() {
|
||||||
const result = await update.check()
|
await session.refresh()
|
||||||
if (result !== 'forbidden') {
|
await update.load()
|
||||||
return
|
}
|
||||||
}
|
|
||||||
|
function notifyOnlyOwner() {
|
||||||
toast.add({
|
toast.add({
|
||||||
color: 'error',
|
color: 'error',
|
||||||
icon: 'i-lucide-circle-x',
|
icon: 'i-lucide-circle-x',
|
||||||
title: 'Only the owner can do this',
|
title: 'Only the owner can do this',
|
||||||
description: 'Ask the owner of this site to update RenewCMS.',
|
description: 'Ask the owner of this site to update RenewCMS.',
|
||||||
})
|
})
|
||||||
await session.refresh()
|
}
|
||||||
|
|
||||||
|
async function onUpdate() {
|
||||||
|
const result = await update.startDownload()
|
||||||
|
if (result === 'limited') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result === 'forbidden') {
|
||||||
|
notifyOnlyOwner()
|
||||||
|
await reloadAfterRoleChange()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (result === 'nothing') {
|
||||||
|
toast.add({
|
||||||
|
color: 'info',
|
||||||
|
icon: 'i-lucide-info',
|
||||||
|
title: 'Nothing to update',
|
||||||
|
description: 'You already have the latest version.',
|
||||||
|
})
|
||||||
await update.load()
|
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)
|
const updateRequested = ref(false)
|
||||||
@@ -115,7 +157,8 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
autoCheckHandled = true
|
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()
|
onCheck()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -123,20 +166,44 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => update.phase,
|
() => [update.phase, update.applyPhase] as const,
|
||||||
(phase) => {
|
([phase, applyPhase]) => {
|
||||||
if (phase === 'limited') {
|
if (phase === 'limited' || applyPhase === 'limited') {
|
||||||
retry.start(update.retryAfter)
|
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 () => {
|
onMounted(async () => {
|
||||||
loadSettings()
|
loadSettings()
|
||||||
await update.load()
|
await update.load()
|
||||||
updateRequested.value = true
|
updateRequested.value = true
|
||||||
})
|
})
|
||||||
onBeforeUnmount(clearSkeletonTimer)
|
onBeforeUnmount(() => {
|
||||||
|
clearSkeletonTimer()
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -198,9 +265,11 @@ onBeforeUnmount(clearSkeletonTimer)
|
|||||||
:status="updatesStatus"
|
:status="updatesStatus"
|
||||||
:role="session.session?.role"
|
:role="session.session?.role"
|
||||||
:phase="updatesPhase"
|
:phase="updatesPhase"
|
||||||
|
:apply-phase="update.applyPhase"
|
||||||
:retry-label="retry.formatted.value"
|
:retry-label="retry.formatted.value"
|
||||||
:retry-active="retry.active.value"
|
:retry-active="retry.active.value"
|
||||||
@check="onCheck"
|
@check="onCheck"
|
||||||
|
@update="onUpdate"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DashboardShell>
|
</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 STATUS_PATH = '/v1/system/update'
|
||||||
export const CHECK_PATH = '/v1/system/update/check'
|
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) {
|
export function hoursAgo(hours: number) {
|
||||||
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
|
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,
|
(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 }
|
const updateCheckHangs: ApiMock = { status: 200, hang: true }
|
||||||
|
|
||||||
function updateApi(
|
function updateApi(
|
||||||
@@ -2046,19 +2084,25 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
|
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
|
||||||
},
|
},
|
||||||
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
|
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
|
||||||
'Update available': { path: '/settings', skip: 'pending' },
|
'Update available': { path: '/settings', skip: 'clock-sensitive' },
|
||||||
'Update available, notes open': { path: '/settings', skip: 'pending' },
|
'Update available, notes open': { path: '/settings', skip: 'clock-sensitive' },
|
||||||
Downloading: { path: '/settings', skip: 'pending' },
|
Downloading: { path: '/settings', api: updateApi('owner', updateDownloading()) },
|
||||||
Verifying: { path: '/settings', skip: 'pending' },
|
Verifying: { path: '/settings', api: updateApi('owner', updateJob('verifying')) },
|
||||||
'Ready to install': { path: '/settings', skip: 'pending' },
|
'Ready to install': { path: '/settings', api: updateApi('owner', updateJob('ready')) },
|
||||||
'Restart: confirm': { path: '/settings', skip: 'pending' },
|
'Restart: confirm': { path: '/settings', skip: 'pending' },
|
||||||
'Restart: request failed': { path: '/settings', skip: 'pending' },
|
'Restart: request failed': { path: '/settings', skip: 'pending' },
|
||||||
'Restarting to install': { path: '/settings', skip: 'pending' },
|
'Restarting to install': { path: '/settings', skip: 'pending' },
|
||||||
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
|
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
|
||||||
'Update done': { path: '/settings', skip: 'pending' },
|
'Update done': { path: '/settings', skip: 'pending' },
|
||||||
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
|
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
|
||||||
'Download failed': { path: '/settings', skip: 'pending' },
|
'Download failed': {
|
||||||
'Verification failed': { path: '/settings', skip: 'pending' },
|
path: '/settings',
|
||||||
|
api: updateApi('owner', updateJob('failed', { error: 'download' })),
|
||||||
|
},
|
||||||
|
'Verification failed': {
|
||||||
|
path: '/settings',
|
||||||
|
api: updateApi('owner', updateJob('failed', { error: 'verification' })),
|
||||||
|
},
|
||||||
'Too many requests (429)': {
|
'Too many requests (429)': {
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
api: updateApi('owner', updateNeverChecked(), {
|
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: {
|
integration: {
|
||||||
Default: {
|
Default: {
|
||||||
@@ -2378,7 +2422,14 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
'GET /v1/system/update': { status: 200, body: updateAvailable() },
|
'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': {
|
'log-in': {
|
||||||
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
||||||
|
|||||||
Reference in new issue
Block a user