From 70460e41704e0f8696ff04a24ffd3be1ef27066c Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Thu, 1 Oct 2026 18:13:45 +0200 Subject: [PATCH] feat: update button (download, verify, stage) --- api/dependecyInjection.go | 78 +- api/handlers/update/updateHandler.go | 83 +- api/pkg/apperr/messages.go | 4 + api/router.go | 13 +- cmd/server/main.go | 6 + docs/deployment.md | 4 + features/updates_download_stage.feature | 334 ++++++ features/updates_download_stage.qa.md | 113 ++ internal/domain/update/updateDomain.go | 9 + internal/domain/update/updateJob.go | 177 +++ .../update/ed25519SignatureVerifier.go | 44 + .../update/execStagedBinaryRunner.go | 29 + .../update/fileHelperDetector.go | 26 + .../infrastructure/update/giteaReleaseFeed.go | 26 +- .../infrastructure/update/httpAssetFetcher.go | 185 +++ .../infrastructure/update/jsonJobStore.go | 90 ++ .../infrastructure/update/osStagingArea.go | 58 + .../update/sqliteDatabaseBackup.go | 87 ++ .../useCases/GetUpdateStatus.go | 26 +- .../useCases/StartUpdateDownload.go | 263 +++++ test/server_version_flag_test.go | 86 ++ test/update_assets_test.go | 344 ++++++ test/update_download_http_test.go | 704 ++++++++++++ test/update_download_test.go | 1010 +++++++++++++++++ test/update_http_test.go | 29 +- test/update_test.go | 6 +- web/src/components/UpdateBanner.vue | 28 +- web/src/components/UpdateProgress.vue | 22 + web/src/components/UpdateStatusBlock.vue | 4 +- web/src/components/UpdatesCard.vue | 268 ++++- web/src/composables/useUpdate.ts | 18 +- web/src/lib/__tests__/updateFormat.spec.ts | 28 + web/src/lib/updateFormat.ts | 25 + web/src/stores/__tests__/update.spec.ts | 138 ++- web/src/stores/update.ts | 80 ++ web/src/views/HomeView.vue | 23 +- web/src/views/SettingsView.vue | 93 +- .../views/__tests__/HomeUpdateReady.spec.ts | 168 +++ .../__tests__/SettingsUpdateDownload.spec.ts | 614 ++++++++++ web/src/views/__tests__/updateTestKit.ts | 30 + web/visual/states.ts | 69 +- 41 files changed, 5345 insertions(+), 97 deletions(-) create mode 100644 features/updates_download_stage.feature create mode 100644 features/updates_download_stage.qa.md create mode 100644 internal/domain/update/updateJob.go create mode 100644 internal/infrastructure/update/ed25519SignatureVerifier.go create mode 100644 internal/infrastructure/update/execStagedBinaryRunner.go create mode 100644 internal/infrastructure/update/fileHelperDetector.go create mode 100644 internal/infrastructure/update/httpAssetFetcher.go create mode 100644 internal/infrastructure/update/jsonJobStore.go create mode 100644 internal/infrastructure/update/osStagingArea.go create mode 100644 internal/infrastructure/update/sqliteDatabaseBackup.go create mode 100644 internal/infrastructure/useCases/StartUpdateDownload.go create mode 100644 test/server_version_flag_test.go create mode 100644 test/update_assets_test.go create mode 100644 test/update_download_http_test.go create mode 100644 test/update_download_test.go create mode 100644 web/src/components/UpdateProgress.vue create mode 100644 web/src/views/__tests__/HomeUpdateReady.spec.ts create mode 100644 web/src/views/__tests__/SettingsUpdateDownload.spec.ts diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index 18ddbc7..ed9f4d8 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -14,9 +14,12 @@ import ( persistence "RenewCMS/internal/infrastructure/persistence/repositories" "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" + "crypto/ed25519" "fmt" + "log" "os" "path/filepath" + "runtime" "time" "github.com/glebarez/sqlite" @@ -56,21 +59,32 @@ type UseCases struct { GetSettingsUseCase *useCases.GetSettingsUseCase GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase CheckForUpdateUseCase *useCases.CheckForUpdateUseCase + StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase } type Repositories struct { - ArticleRepo domainArticle.Repository - UserRepo domainUser.Repository - ImageRepo domainImage.Repository - InviteRepo domainInvite.Repository - MailRepo domainMail.Repository - ReleaseFeed domainUpdate.ReleaseFeed - UpdateStore domainUpdate.StatusStore + ArticleRepo domainArticle.Repository + UserRepo domainUser.Repository + ImageRepo domainImage.Repository + InviteRepo domainInvite.Repository + MailRepo domainMail.Repository + ReleaseFeed domainUpdate.ReleaseFeed + UpdateStore domainUpdate.StatusStore + UpdateJobs domainUpdate.JobStore + UpdateHelper domainUpdate.HelperDetector + AssetFetcher domainUpdate.AssetFetcher + SignatureVerifier domainUpdate.SignatureVerifier + StagedRunner domainUpdate.StagedBinaryRunner + DatabaseBackup domainUpdate.DatabaseBackup } const ( sqliteBusyTimeoutMs = 5000 updateStatusFileName = "update-status.json" + updateJobFileName = "update-job.json" + updateHelperFileName = "update-helper.json" + updatesDirName = "updates" + backupsDirName = "backups" ) func SQLiteDSN(dbName string) string { @@ -101,15 +115,30 @@ func getDb(dbName string, uploadDir string) *gorm.DB { return db } +func releasePublicKey() ed25519.PublicKey { + key, err := update.EmbeddedPublicKey() + if err != nil { + panic("Unable to read the embedded release key: " + err.Error()) + } + return key +} + func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { + dataDir := filepath.Dir(settings.DBFile) return &Repositories{ - ArticleRepo: persistence.NewArticleRepository(db), - UserRepo: persistence.NewUserRepository(db), - ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), - InviteRepo: persistence.NewInviteRepository(db), - MailRepo: mailer.NewMailRepository(settings.Smtp), - ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL), - UpdateStore: update.NewJSONStatusStore(filepath.Join(filepath.Dir(settings.DBFile), updateStatusFileName)), + ArticleRepo: persistence.NewArticleRepository(db), + UserRepo: persistence.NewUserRepository(db), + ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), + InviteRepo: persistence.NewInviteRepository(db), + MailRepo: mailer.NewMailRepository(settings.Smtp), + ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL), + UpdateStore: update.NewJSONStatusStore(filepath.Join(dataDir, updateStatusFileName)), + UpdateJobs: update.NewJSONJobStore(filepath.Join(dataDir, updateJobFileName)), + UpdateHelper: update.NewFileHelperDetector(filepath.Join(dataDir, updateHelperFileName)), + AssetFetcher: update.NewHTTPAssetFetcher(settings.UpdateFeedURL), + SignatureVerifier: update.NewEd25519SignatureVerifier(releasePublicKey()), + StagedRunner: update.NewExecStagedBinaryRunner(), + DatabaseBackup: update.NewSQLiteDatabaseBackup(db, filepath.Join(dataDir, backupsDirName), time.Now), } } @@ -146,11 +175,30 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases { MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), - GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, settings.Version), + GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, repos.UpdateJobs, repos.UpdateHelper, settings.Version), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now), + StartUpdateDownloadUseCase: startUpdateDownloadUseCase(repos, settings), } } +func startUpdateDownloadUseCase(repos *Repositories, settings config.Settings) *useCases.StartUpdateDownloadUseCase { + return useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{ + Store: repos.UpdateStore, + Jobs: repos.UpdateJobs, + Helper: repos.UpdateHelper, + Fetcher: repos.AssetFetcher, + Verifier: repos.SignatureVerifier, + Runner: repos.StagedRunner, + Backup: repos.DatabaseBackup, + Staging: update.NewOSStagingArea(filepath.Join(filepath.Dir(settings.DBFile), updatesDirName)), + Logger: log.Default(), + CurrentVersion: settings.Version, + Platform: domainUpdate.Platform{OS: runtime.GOOS, Arch: runtime.GOARCH}, + FeedURL: settings.UpdateFeedURL, + Now: time.Now, + }) +} + func InitContainer(settings config.Settings) (*UseCases, func() error) { db := getDb(settings.DBFile, settings.UploadDir) repos := getRepositories(db, settings) diff --git a/api/handlers/update/updateHandler.go b/api/handlers/update/updateHandler.go index 7ccee24..6fac753 100644 --- a/api/handlers/update/updateHandler.go +++ b/api/handlers/update/updateHandler.go @@ -1,39 +1,100 @@ package update import ( + "RenewCMS/api/pkg/apperr" + "RenewCMS/api/pkg/claims" domain "RenewCMS/internal/domain/update" + "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/useCases" "encoding/json" + "errors" "net/http" ) type Handler struct { - GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase - CheckForUpdateUseCase *useCases.CheckForUpdateUseCase + GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase + CheckForUpdateUseCase *useCases.CheckForUpdateUseCase + StartUpdateDownloadUseCase *useCases.StartUpdateDownloadUseCase + GetUserUseCase *useCases.GetUserUseCase } -func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase) *Handler { - return &Handler{GetUpdateStatusUseCase: getStatus, CheckForUpdateUseCase: checkForUpdate} +func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase, startDownload *useCases.StartUpdateDownloadUseCase, getUser *useCases.GetUserUseCase) *Handler { + return &Handler{ + GetUpdateStatusUseCase: getStatus, + CheckForUpdateUseCase: checkForUpdate, + StartUpdateDownloadUseCase: startDownload, + GetUserUseCase: getUser, + } } -func (h *Handler) GetStatus(w http.ResponseWriter, _ *http.Request) { - writeStatus(w, h.GetUpdateStatusUseCase.GetStatus()) +func (h *Handler) GetStatus(w http.ResponseWriter, r *http.Request) { + isOwner, ok := h.isOwner(w, r) + if !ok { + return + } + writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner)) } -func (h *Handler) Check(w http.ResponseWriter, _ *http.Request) { - status, err := h.CheckForUpdateUseCase.Check() - if err != nil { +func (h *Handler) Check(w http.ResponseWriter, r *http.Request) { + if _, err := h.CheckForUpdateUseCase.Check(); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - writeStatus(w, status) + isOwner, ok := h.isOwner(w, r) + if !ok { + return + } + writeStatus(w, http.StatusOK, h.GetUpdateStatusUseCase.GetStatus(isOwner)) } -func writeStatus(w http.ResponseWriter, status domain.UpdateStatus) { +func (h *Handler) Download(w http.ResponseWriter, _ *http.Request) { + status, err := h.StartUpdateDownloadUseCase.Start() + if err != nil { + writeDownloadError(w, err) + return + } + writeStatus(w, http.StatusAccepted, status) +} + +func (h *Handler) isOwner(w http.ResponseWriter, r *http.Request) (bool, bool) { + userId, ok := claims.UserId(r) + if !ok { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return false, false + } + currentUser, err := h.GetUserUseCase.GetUser(userId) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + http.Error(w, apperr.UnauthorizedErrorMessage, http.StatusUnauthorized) + return false, false + } + http.Error(w, "Internal server error", http.StatusInternalServerError) + return false, false + } + return currentUser.Role == user.RoleOwner, true +} + +func writeDownloadError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, domain.ErrNotPrepared): + http.Error(w, apperr.UpdateNotPreparedErrorMessage, http.StatusConflict) + case errors.Is(err, domain.ErrJobInProgress): + http.Error(w, apperr.UpdateAlreadyRunningErrorMessage, http.StatusConflict) + case errors.Is(err, domain.ErrNothingToUpdate): + http.Error(w, apperr.NothingToUpdateErrorMessage, http.StatusBadRequest) + case errors.Is(err, domain.ErrPreflightRefused): + http.Error(w, apperr.UpdateRefusedErrorMessage, http.StatusUnprocessableEntity) + default: + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func writeStatus(w http.ResponseWriter, code int, status domain.UpdateStatus) { body, err := json.Marshal(status) if err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) return } + w.WriteHeader(code) _, _ = w.Write(body) } diff --git a/api/pkg/apperr/messages.go b/api/pkg/apperr/messages.go index 5204d86..64b7754 100644 --- a/api/pkg/apperr/messages.go +++ b/api/pkg/apperr/messages.go @@ -29,3 +29,7 @@ const MediaQueryErrorMessage = "The media query is invalid." const MediaFilenameErrorMessage = "Enter a filename." const MediaMergeConflictErrorMessage = "Some of the images to merge changed. Refresh and try again." const MediaMergeRequestErrorMessage = "Choose an image to keep and at least one other image to merge into it." +const UpdateNotPreparedErrorMessage = "This server cannot update itself yet." +const UpdateAlreadyRunningErrorMessage = "An update is already running or waiting to be installed." +const NothingToUpdateErrorMessage = "There is nothing to update." +const UpdateRefusedErrorMessage = "This update cannot be downloaded." diff --git a/api/router.go b/api/router.go index 654d7c7..29c919b 100644 --- a/api/router.go +++ b/api/router.go @@ -132,14 +132,16 @@ func NewSettingsRouter(container *UseCases) http.Handler { } const ( - updateCheckMaxAttempts = 5 - updateCheckWindow = time.Minute + updateCheckMaxAttempts = 5 + updateCheckWindow = time.Minute + updateDownloadMaxAttempts = 5 + updateDownloadWindow = time.Minute ) func NewSystemRouter(container *UseCases) http.Handler { r := chi.NewRouter() - updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase) + updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase, container.StartUpdateDownloadUseCase, container.GetUserUseCase) r.Use(jwtauth.Verifier(container.Token)) r.Use(jwtauth.Authenticator(container.Token)) @@ -151,6 +153,11 @@ func NewSystemRouter(container *UseCases) http.Handler { r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow)) r.Post("/update/check", updateHandler.Check) }) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireOwner(container.GetUserUseCase)) + r.Use(middleware.NewUserRateLimiter(updateDownloadMaxAttempts, updateDownloadWindow)) + r.Post("/update/download", updateHandler.Download) + }) return r } diff --git a/cmd/server/main.go b/cmd/server/main.go index 78ea27f..263a362 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,7 +50,13 @@ var envDefaults = map[string]string{ "UPDATE_FEED_URL": "https://git.floriansylvain.fr", } +const versionFlag = "--version" + func main() { + if len(os.Args) == 2 && os.Args[1] == versionFlag { + fmt.Println(version) + return + } if err := run(); err != nil { log.Fatal(err) } diff --git a/docs/deployment.md b/docs/deployment.md index af831a2..94296f9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -35,6 +35,10 @@ To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, wi The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page only reports a newer version for now: the site does not update itself. +### Update downloads + +Once the restart helper is installed (it writes `update-helper.json` next to `DB_FILE`), the owner's Update button downloads the new binary, `checksums.txt` and `checksums.txt.sig` from the release feed host over https only, checks the ed25519 signature and the SHA-256, runs the staged binary with `--version`, copies the database into `backups/` (the last 3 copies are kept) and stages everything under `updates//`, all next to `DB_FILE`. The job record is `update-job.json`. Nothing running is replaced and the site is not restarted. `renewcms --version` prints the build version and exits. + ## 2. Lay out the server The following is a suggested layout for Linux: diff --git a/features/updates_download_stage.feature b/features/updates_download_stage.feature new file mode 100644 index 0000000..d60f6ad --- /dev/null +++ b/features/updates_download_stage.feature @@ -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 --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 /updates//: the binary, checksums.txt and checksums.txt.sig + And the asset to download is named renewcms___, 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 /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 /updates/v2.3.0-beta/ + And a database backup exists in /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 / and the release lists all six binaries + When the update starts + Then the fetcher is asked for "", "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 sends POST /v1/system/update/download + Then the status is + 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 + 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 + 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 + When the job reaches the check + Then GET reports state "failed", error "verification", progress null + And /updates// 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 + Then GET reports state "failed", error "download" + And /updates// 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) diff --git a/features/updates_download_stage.qa.md b/features/updates_download_stage.qa.md new file mode 100644 index 0000000..e233d06 --- /dev/null +++ b/features/updates_download_stage.qa.md @@ -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//`, 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 + `/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//` + 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 `/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 `/updates//` and one file in `/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. diff --git a/internal/domain/update/updateDomain.go b/internal/domain/update/updateDomain.go index 3e76098..53c1c9f 100644 --- a/internal/domain/update/updateDomain.go +++ b/internal/domain/update/updateDomain.go @@ -19,17 +19,25 @@ type ReleaseNotes struct { Items []string `json:"items"` } +type Asset struct { + Name string `json:"name"` + URL string `json:"url"` + Size int64 `json:"size"` +} + type Release struct { Tag string URL string PublishedAt time.Time Notes ReleaseNotes + Assets []Asset } type CheckResult struct { Latest string `json:"latest"` ReleaseURL string `json:"release_url"` Notes ReleaseNotes `json:"notes"` + Assets []Asset `json:"assets"` CheckedAt time.Time `json:"checked_at"` } @@ -58,6 +66,7 @@ func NewResult(release Release, checkedAt time.Time) CheckResult { Latest: release.Tag, ReleaseURL: release.URL, Notes: release.Notes, + Assets: release.Assets, CheckedAt: checkedAt.UTC(), } } diff --git a/internal/domain/update/updateJob.go b/internal/domain/update/updateJob.go new file mode 100644 index 0000000..af5206a --- /dev/null +++ b/internal/domain/update/updateJob.go @@ -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 +} diff --git a/internal/infrastructure/update/ed25519SignatureVerifier.go b/internal/infrastructure/update/ed25519SignatureVerifier.go new file mode 100644 index 0000000..365eebb --- /dev/null +++ b/internal/infrastructure/update/ed25519SignatureVerifier.go @@ -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 +} diff --git a/internal/infrastructure/update/execStagedBinaryRunner.go b/internal/infrastructure/update/execStagedBinaryRunner.go new file mode 100644 index 0000000..8474734 --- /dev/null +++ b/internal/infrastructure/update/execStagedBinaryRunner.go @@ -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 +} diff --git a/internal/infrastructure/update/fileHelperDetector.go b/internal/infrastructure/update/fileHelperDetector.go new file mode 100644 index 0000000..7daaac9 --- /dev/null +++ b/internal/infrastructure/update/fileHelperDetector.go @@ -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 +} diff --git a/internal/infrastructure/update/giteaReleaseFeed.go b/internal/infrastructure/update/giteaReleaseFeed.go index d7044a9..569f79d 100644 --- a/internal/infrastructure/update/giteaReleaseFeed.go +++ b/internal/infrastructure/update/giteaReleaseFeed.go @@ -24,11 +24,18 @@ type GiteaReleaseFeed struct { var _ domain.ReleaseFeed = &GiteaReleaseFeed{} type giteaRelease struct { - TagName string `json:"tag_name"` - HTMLURL string `json:"html_url"` - Body string `json:"body"` - Draft bool `json:"draft"` - PublishedAt time.Time `json:"published_at"` + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + Draft bool `json:"draft"` + PublishedAt time.Time `json:"published_at"` + Assets []giteaAsset `json:"assets"` +} + +type giteaAsset struct { + Name string `json:"name"` + Size int64 `json:"size"` + DownloadURL string `json:"browser_download_url"` } func NewGiteaReleaseFeed(baseURL string) *GiteaReleaseFeed { @@ -73,7 +80,16 @@ func toReleases(raw []giteaRelease) []domain.Release { URL: item.HTMLURL, PublishedAt: item.PublishedAt, Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body), + Assets: toAssets(item.Assets), }) } return releases } + +func toAssets(raw []giteaAsset) []domain.Asset { + assets := make([]domain.Asset, 0, len(raw)) + for _, item := range raw { + assets = append(assets, domain.Asset{Name: item.Name, URL: item.DownloadURL, Size: item.Size}) + } + return assets +} diff --git a/internal/infrastructure/update/httpAssetFetcher.go b/internal/infrastructure/update/httpAssetFetcher.go new file mode 100644 index 0000000..f1a33ff --- /dev/null +++ b/internal/infrastructure/update/httpAssetFetcher.go @@ -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) +} diff --git a/internal/infrastructure/update/jsonJobStore.go b/internal/infrastructure/update/jsonJobStore.go new file mode 100644 index 0000000..7f69fb2 --- /dev/null +++ b/internal/infrastructure/update/jsonJobStore.go @@ -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() +} diff --git a/internal/infrastructure/update/osStagingArea.go b/internal/infrastructure/update/osStagingArea.go new file mode 100644 index 0000000..787f7ef --- /dev/null +++ b/internal/infrastructure/update/osStagingArea.go @@ -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)) +} diff --git a/internal/infrastructure/update/sqliteDatabaseBackup.go b/internal/infrastructure/update/sqliteDatabaseBackup.go new file mode 100644 index 0000000..63ac3db --- /dev/null +++ b/internal/infrastructure/update/sqliteDatabaseBackup.go @@ -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 +} diff --git a/internal/infrastructure/useCases/GetUpdateStatus.go b/internal/infrastructure/useCases/GetUpdateStatus.go index 95fe41e..9938dfc 100644 --- a/internal/infrastructure/useCases/GetUpdateStatus.go +++ b/internal/infrastructure/useCases/GetUpdateStatus.go @@ -4,17 +4,29 @@ import "RenewCMS/internal/domain/update" type GetUpdateStatusUseCase struct { store update.StatusStore + jobs update.JobStore + helper update.HelperDetector currentVersion string } -func NewGetUpdateStatusUseCase(store update.StatusStore, currentVersion string) *GetUpdateStatusUseCase { - return &GetUpdateStatusUseCase{store: store, currentVersion: currentVersion} +func NewGetUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, currentVersion string) *GetUpdateStatusUseCase { + return &GetUpdateStatusUseCase{store: store, jobs: jobs, helper: helper, currentVersion: currentVersion} } -func (g *GetUpdateStatusUseCase) GetStatus() update.UpdateStatus { - result, found := g.store.Load() - if !found { - return update.NewStatus(g.currentVersion, nil) +func (g *GetUpdateStatusUseCase) GetStatus(isOwner bool) update.UpdateStatus { + return buildUpdateStatus(g.store, g.jobs, g.currentVersion, isOwner && g.helper.Installed()) +} + +func buildUpdateStatus(store update.StatusStore, jobs update.JobStore, currentVersion string, canApply bool) update.UpdateStatus { + var status update.UpdateStatus + if result, found := store.Load(); found { + status = update.NewStatus(currentVersion, &result) + } else { + status = update.NewStatus(currentVersion, nil) } - return update.NewStatus(g.currentVersion, &result) + if job, found := jobs.Load(); found { + status = status.WithJob(job) + } + status.CanApply = canApply + return status } diff --git a/internal/infrastructure/useCases/StartUpdateDownload.go b/internal/infrastructure/useCases/StartUpdateDownload.go new file mode 100644 index 0000000..7a623b0 --- /dev/null +++ b/internal/infrastructure/useCases/StartUpdateDownload.go @@ -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) + } +} diff --git a/test/server_version_flag_test.go b/test/server_version_flag_test.go new file mode 100644 index 0000000..e18c6ca --- /dev/null +++ b/test/server_version_flag_test.go @@ -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") + } + }) +} diff --git a/test/update_assets_test.go b/test/update_assets_test.go new file mode 100644 index 0000000..a750bcd --- /dev/null +++ b/test/update_assets_test.go @@ -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) + } + } + }) +} diff --git a/test/update_download_http_test.go b/test/update_download_http_test.go new file mode 100644 index 0000000..2fd90da --- /dev/null +++ b/test/update_download_http_test.go @@ -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") + } + }) +} diff --git a/test/update_download_test.go b/test/update_download_test.go new file mode 100644 index 0000000..8046f7d --- /dev/null +++ b/test/update_download_test.go @@ -0,0 +1,1010 @@ +package test + +import ( + "RenewCMS/api" + "RenewCMS/internal/domain/update" + "RenewCMS/internal/infrastructure/persistence/models" + infraUpdate "RenewCMS/internal/infrastructure/update" + "RenewCMS/internal/infrastructure/useCases" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" +) + +const ( + downloadTag = "v2.3.0-beta" + downloadFeedURL = "https://git.example" + downloadAssetPrefix = "https://git.example/dl/" + boardTotalBytes = 27262976 + boardDoneBytes = 11534336 +) + +var ( + errDownloadDisk = errors.New("write failed: no space left on device") + errDownloadNetwork = errors.New("connection reset by peer") + errDownloadBackup = errors.New("vacuum into failed") + errDownloadTooLarge = errors.New("the asset is larger than its size cap") + errAssetStatusNotFound = errors.New("the asset server answered 404") + errDownloadRunnerFail = errors.New("fork/exec: exec format error") + errDownloadChmod = errors.New("operation not permitted") + errDownloadStat = errors.New("stat failed") +) + +type fakeJobStore struct { + mu sync.Mutex + job update.Job + found bool + saveErr error + history []string + bytes []int64 +} + +var _ update.JobStore = &fakeJobStore{} + +func (f *fakeJobStore) Load() (update.Job, bool) { + f.mu.Lock() + defer f.mu.Unlock() + return f.job, f.found +} + +func (f *fakeJobStore) Save(job update.Job) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.saveErr != nil { + return f.saveErr + } + f.job = job + f.found = true + if job.State == update.StateDownloading { + f.bytes = append(f.bytes, job.DownloadedBytes) + } + if len(f.history) == 0 || f.history[len(f.history)-1] != job.State { + f.history = append(f.history, job.State) + } + return nil +} + +func (f *fakeJobStore) put(job update.Job) { + f.mu.Lock() + defer f.mu.Unlock() + f.job = job + f.found = true +} + +func (f *fakeJobStore) downloadedBytes() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.bytes...) +} + +func (f *fakeJobStore) states() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.history...) +} + +type fakeHelperDetector struct { + installed bool +} + +var _ update.HelperDetector = &fakeHelperDetector{} + +func (f *fakeHelperDetector) Installed() bool { + return f.installed +} + +type fakeUpdateLogger struct { + mu sync.Mutex + messages []string +} + +var _ update.Logger = &fakeUpdateLogger{} + +func (f *fakeUpdateLogger) Printf(format string, args ...any) { + f.mu.Lock() + defer f.mu.Unlock() + f.messages = append(f.messages, fmt.Sprintf(format, args...)) +} + +func (f *fakeUpdateLogger) text() string { + f.mu.Lock() + defer f.mu.Unlock() + return strings.Join(f.messages, "\n") +} + +type fakeAssetFetcher struct { + mu sync.Mutex + files map[string][]byte + errors map[string]error + partial map[string]bool + calls map[string]int + during func(name string, report update.ProgressFunc) +} + +var _ update.AssetFetcher = &fakeAssetFetcher{} + +func (f *fakeAssetFetcher) Fetch(asset update.Asset, destination string, report update.ProgressFunc) error { + f.mu.Lock() + f.calls[asset.Name]++ + content := f.files[asset.Name] + failure := f.errors[asset.Name] + partial := f.partial[asset.Name] + during := f.during + f.mu.Unlock() + + if during != nil { + during(asset.Name, report) + } + if failure != nil { + if partial { + _ = os.WriteFile(destination, content[:len(content)/2], 0o644) + } + return failure + } + report(int64(len(content)), asset.Size) + return os.WriteFile(destination, content, 0o644) +} + +func (f *fakeAssetFetcher) requested() []string { + f.mu.Lock() + defer f.mu.Unlock() + names := make([]string, 0, len(f.calls)) + for name := range f.calls { + names = append(names, name) + } + return names +} + +func (f *fakeAssetFetcher) callsFor(name string) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls[name] +} + +func (f *fakeAssetFetcher) totalCalls() int { + f.mu.Lock() + defer f.mu.Unlock() + total := 0 + for _, count := range f.calls { + total += count + } + return total +} + +type fakeStagedRunner struct { + mu sync.Mutex + output string + err error + paths []string +} + +var _ update.StagedBinaryRunner = &fakeStagedRunner{} + +func (f *fakeStagedRunner) Version(binaryPath string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.paths = append(f.paths, binaryPath) + return f.output, f.err +} + +type fakeDatabaseBackup struct { + mu sync.Mutex + err error + calls int + gate chan struct{} + entered chan struct{} + jobState func() string + seen []string +} + +var _ update.DatabaseBackup = &fakeDatabaseBackup{} + +func (f *fakeDatabaseBackup) Backup() (string, error) { + f.mu.Lock() + f.calls++ + gate, entered := f.gate, f.entered + if f.jobState != nil { + f.seen = append(f.seen, f.jobState()) + } + err := f.err + f.mu.Unlock() + if entered != nil { + close(entered) + } + if gate != nil { + <-gate + } + return "", err +} + +func (f *fakeDatabaseBackup) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +type faultyStagingArea struct { + *infraUpdate.OSStagingArea + prepareErr error + sizeErr error + readErr error + chmodErr error + removeErr error + removedTags []string + mu sync.Mutex +} + +var _ update.StagingArea = &faultyStagingArea{} + +func (f *faultyStagingArea) Prepare(tag string) error { + if f.prepareErr != nil { + return f.prepareErr + } + return f.OSStagingArea.Prepare(tag) +} + +func (f *faultyStagingArea) Size(path string) (int64, error) { + if f.sizeErr != nil { + return 0, f.sizeErr + } + return f.OSStagingArea.Size(path) +} + +func (f *faultyStagingArea) ReadChecksums(path string) (string, error) { + if f.readErr != nil { + return "", f.readErr + } + return f.OSStagingArea.ReadChecksums(path) +} + +func (f *faultyStagingArea) MakeExecutable(path string) error { + if f.chmodErr != nil { + return f.chmodErr + } + return f.OSStagingArea.MakeExecutable(path) +} + +func (f *faultyStagingArea) Remove(tag string) error { + f.mu.Lock() + f.removedTags = append(f.removedTags, tag) + f.mu.Unlock() + if f.removeErr != nil { + return f.removeErr + } + return f.OSStagingArea.Remove(tag) +} + +func (f *faultyStagingArea) removed() []string { + f.mu.Lock() + defer f.mu.Unlock() + return append([]string(nil), f.removedTags...) +} + +type downloadRig struct { + t *testing.T + dataDir string + updatesDir string + platform update.Platform + assetName string + publicKey ed25519.PublicKey + privateKey ed25519.PrivateKey + store *fakeUpdateStore + jobs *fakeJobStore + helper *fakeHelperDetector + fetcher *fakeAssetFetcher + runner *fakeStagedRunner + backup *fakeDatabaseBackup + staging *faultyStagingArea + realBackup update.DatabaseBackup + logger *fakeUpdateLogger + start *useCases.StartUpdateDownloadUseCase + status *useCases.GetUpdateStatusUseCase +} + +func newDownloadRig(t *testing.T) *downloadRig { + return newDownloadRigFor(t, update.Platform{OS: "linux", Arch: "amd64"}, []byte("renewcms test binary 2.3.0")) +} + +func newDownloadRigFor(t *testing.T, platform update.Platform, binary []byte) *downloadRig { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + dataDir := t.TempDir() + rig := &downloadRig{ + t: t, + dataDir: dataDir, + updatesDir: filepath.Join(dataDir, "updates"), + platform: platform, + assetName: update.AssetName(downloadTag, platform), + publicKey: publicKey, + privateKey: privateKey, + store: &fakeUpdateStore{}, + jobs: &fakeJobStore{}, + helper: &fakeHelperDetector{installed: true}, + fetcher: &fakeAssetFetcher{ + files: map[string][]byte{}, + errors: map[string]error{}, + partial: map[string]bool{}, + calls: map[string]int{}, + }, + runner: &fakeStagedRunner{output: downloadTag}, + backup: &fakeDatabaseBackup{}, + logger: &fakeUpdateLogger{}, + } + rig.staging = &faultyStagingArea{OSStagingArea: infraUpdate.NewOSStagingArea(rig.updatesDir)} + rig.backup.jobState = func() string { + job, _ := rig.jobs.Load() + return job.State + } + rig.fetcher.files[rig.assetName] = binary + rig.signChecksums(rig.checksumsFor(rig.assetName, binary), privateKey) + rig.publishAssets() + rig.rebuild() + return rig +} + +func (r *downloadRig) rebuild() { + verifier := infraUpdate.NewEd25519SignatureVerifier(r.publicKey) + var backup update.DatabaseBackup = r.backup + if r.realBackup != nil { + backup = r.realBackup + } + r.start = useCases.NewStartUpdateDownloadUseCase(useCases.StartUpdateDownloadDependencies{ + Store: r.store, + Jobs: r.jobs, + Helper: r.helper, + Fetcher: r.fetcher, + Verifier: verifier, + Runner: r.runner, + Backup: backup, + Staging: r.staging, + Logger: r.logger, + CurrentVersion: updateRunning, + Platform: r.platform, + FeedURL: downloadFeedURL, + Now: func() time.Time { return updateTestCheckedAt }, + }) + r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, updateRunning) +} + +func (r *downloadRig) checksumsFor(name string, content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) + " " + name + "\n" +} + +func (r *downloadRig) signChecksums(checksums string, key ed25519.PrivateKey) { + signature := base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(checksums))) + "\n" + r.fetcher.files[update.ChecksumsAssetName] = []byte(checksums) + r.fetcher.files[update.SignatureAssetName] = []byte(signature) +} + +func (r *downloadRig) publishAssets() { + result := newStoredUpdateResult() + for name, content := range r.fetcher.files { + result.Assets = append(result.Assets, update.Asset{Name: name, URL: downloadAssetPrefix + name, Size: int64(len(content))}) + } + r.store.result = result + r.store.found = true +} + +func (r *downloadRig) refreshAssetSizes() { + for index, asset := range r.store.result.Assets { + r.store.result.Assets[index].Size = int64(len(r.fetcher.files[asset.Name])) + } +} + +func (r *downloadRig) assetIndex(name string) int { + for index, asset := range r.store.result.Assets { + if asset.Name == name { + return index + } + } + r.t.Fatalf("asset %s is not published", name) + return -1 +} + +func (r *downloadRig) setAsset(name string, change func(*update.Asset)) { + index := r.assetIndex(name) + change(&r.store.result.Assets[index]) +} + +func (r *downloadRig) dropAsset(name string) { + index := r.assetIndex(name) + assets := r.store.result.Assets + r.store.result.Assets = append(append([]update.Asset(nil), assets[:index]...), assets[index+1:]...) +} + +func (r *downloadRig) stagedDir() string { + return filepath.Join(r.updatesDir, downloadTag) +} + +func (r *downloadRig) waitForJob(states ...string) update.Job { + r.t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if job, found := r.jobs.Load(); found { + for _, state := range states { + if job.State == state { + return job + } + } + } + time.Sleep(2 * time.Millisecond) + } + job, _ := r.jobs.Load() + r.t.Fatalf("job never reached %v, last record %+v, log: %s", states, job, r.logger.text()) + return update.Job{} +} + +func assertStagedDirGone(t *testing.T, rig *downloadRig) { + t.Helper() + if _, err := os.Stat(rig.stagedDir()); !os.IsNotExist(err) { + t.Errorf("staged directory still exists: %v", err) + } +} + +func TestAssetNameFollowsThePlatform(t *testing.T) { + cases := []struct { + os, arch, asset string + }{ + {"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"}, + } + for _, c := range cases { + t.Run(c.os+"/"+c.arch, func(t *testing.T) { + rig := newDownloadRigFor(t, update.Platform{OS: c.os, Arch: c.arch}, []byte("binary")) + for _, other := range []string{"linux_amd64", "linux_arm64", "darwin_arm64", "windows_amd64.exe"} { + name := "renewcms_" + downloadTag + "_" + other + if _, listed := update.FindAsset(rig.store.result.Assets, name); !listed { + rig.store.result.Assets = append(rig.store.result.Assets, update.Asset{Name: name, URL: downloadAssetPrefix + name, Size: 1}) + } + } + + if _, err := rig.start.Start(); err != nil { + t.Fatal(err) + } + rig.waitForJob(update.StateReady, update.StateFailed) + + got := rig.fetcher.requested() + want := map[string]bool{c.asset: true, "checksums.txt": true, "checksums.txt.sig": true} + if len(got) != len(want) { + t.Fatalf("requested %v, want %v", got, want) + } + for _, name := range got { + if !want[name] { + t.Errorf("unexpected asset requested: %s", name) + } + } + }) + } +} + +func TestJobProgressIsAFlooredPercentOfBytes(t *testing.T) { + job := update.Job{State: update.StateDownloading, DownloadedBytes: boardDoneBytes, TotalBytes: boardTotalBytes} + + progress := job.Progress() + + if progress == nil || progress.Percent != 42 || progress.DownloadedBytes != boardDoneBytes || progress.TotalBytes != boardTotalBytes { + t.Errorf("progress = %+v", progress) + } + for _, state := range []string{update.StateVerifying, update.StateReady, update.StateFailed} { + if (update.Job{State: state, DownloadedBytes: 5, TotalBytes: 10}).Progress() != nil { + t.Errorf("progress must be nil in %s", state) + } + } + if (update.Job{State: update.StateDownloading}).Progress() != nil { + t.Error("progress needs a known total") + } +} + +func TestStatusUseCaseCanApplyOnlyForOwnersWithTheHelper(t *testing.T) { + rig := newDownloadRig(t) + + if !rig.status.GetStatus(true).CanApply { + t.Error("owner with helper must be able to apply") + } + if rig.status.GetStatus(false).CanApply { + t.Error("admin must never be able to apply") + } + rig.helper.installed = false + if rig.status.GetStatus(true).CanApply { + t.Error("without the helper nobody can apply") + } +} + +func TestStartUpdateDownloadUseCaseHappyPath(t *testing.T) { + rig := newDownloadRig(t) + gate := make(chan struct{}) + entered := make(chan struct{}) + rig.backup.gate = gate + rig.backup.entered = entered + + status, err := rig.start.Start() + + if err != nil || status.State != update.StateDownloading || status.StartedAt == nil { + t.Fatalf("status = %+v, err = %v", status, err) + } + if status.Progress == nil || status.Progress.TotalBytes != int64(len(rig.fetcher.files[rig.assetName])) { + t.Errorf("progress = %+v", status.Progress) + } + <-entered + if during := rig.status.GetStatus(true); during.State != update.StateVerifying { + t.Errorf("state during the backup = %q, want verifying", during.State) + } + close(gate) + job := rig.waitForJob(update.StateReady, update.StateFailed) + + if job.State != update.StateReady || job.Error != "" { + t.Fatalf("job = %+v, log: %s", job, rig.logger.text()) + } + for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} { + if _, err := os.Stat(filepath.Join(rig.stagedDir(), name)); err != nil { + t.Errorf("staged file %s: %v", name, err) + } + } + if got := rig.backup.seen; len(got) != 1 || got[0] != update.StateVerifying { + t.Errorf("job state seen by the backup = %v", got) + } + want := []string{update.StateDownloading, update.StateVerifying, update.StateReady} + if got := rig.jobs.states(); strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("states = %v, want %v", got, want) + } + if ready := rig.status.GetStatus(true); ready.State != update.StateReady || ready.Progress != nil || ready.Error != nil || ready.Latest != downloadTag { + t.Errorf("ready status = %+v", ready) + } +} + +func TestStartUpdateDownloadUseCaseGuards(t *testing.T) { + t.Run("without the helper nothing starts", func(t *testing.T) { + rig := newDownloadRig(t) + rig.helper.installed = false + if _, err := rig.start.Start(); !errors.Is(err, update.ErrNotPrepared) { + t.Errorf("err = %v", err) + } + if rig.fetcher.totalCalls() != 0 { + t.Error("the fetcher must not be called") + } + }) + + t.Run("the helper check comes before the availability check", func(t *testing.T) { + rig := newDownloadRig(t) + rig.helper.installed = false + rig.store.found = false + if _, err := rig.start.Start(); !errors.Is(err, update.ErrNotPrepared) { + t.Errorf("err = %v", err) + } + }) + + t.Run("a stored job comes before the availability check", func(t *testing.T) { + rig := newDownloadRig(t) + rig.jobs.put(update.Job{State: update.StateReady}) + rig.store.found = false + if _, err := rig.start.Start(); !errors.Is(err, update.ErrJobInProgress) { + t.Errorf("err = %v", err) + } + }) + + t.Run("nothing stored", func(t *testing.T) { + rig := newDownloadRig(t) + rig.store.found = false + if _, err := rig.start.Start(); !errors.Is(err, update.ErrNothingToUpdate) { + t.Errorf("err = %v", err) + } + }) + + t.Run("a job record that cannot be saved is an error", func(t *testing.T) { + rig := newDownloadRig(t) + rig.jobs.saveErr = errRepositoryFailure + if _, err := rig.start.Start(); !errors.Is(err, errRepositoryFailure) { + t.Errorf("err = %v", err) + } + if rig.fetcher.totalCalls() != 0 { + t.Error("the fetcher must not be called") + } + }) +} + +func TestTwoStartsAtOnceRunOneJob(t *testing.T) { + rig := newDownloadRig(t) + gate := make(chan struct{}) + rig.fetcher.during = func(name string, _ update.ProgressFunc) { + if name == rig.assetName { + <-gate + } + } + results := make(chan error, 2) + var ready sync.WaitGroup + ready.Add(2) + for range 2 { + go func() { + ready.Done() + ready.Wait() + _, err := rig.start.Start() + results <- err + }() + } + + first, second := <-results, <-results + close(gate) + rig.waitForJob(update.StateReady, update.StateFailed) + + accepted := 0 + for _, err := range []error{first, second} { + if err == nil { + accepted++ + } else if !errors.Is(err, update.ErrJobInProgress) { + t.Errorf("unexpected err %v", err) + } + } + if accepted != 1 { + t.Fatalf("accepted = %d, want 1", accepted) + } + 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)) + } + } +} + +func TestJSONJobStorePersistence(t *testing.T) { + startedAt := time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC) + + t.Run("round trip", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "update-job.json") + job := update.Job{Tag: downloadTag, State: update.StateFailed, Error: update.ErrorKindVerification, StartedAt: startedAt} + if err := infraUpdate.NewJSONJobStore(path).Save(job); err != nil { + t.Fatal(err) + } + + got, found := infraUpdate.NewJSONJobStore(path).Load() + + if !found || got.Tag != downloadTag || got.State != update.StateFailed || got.Error != "verification" || !got.StartedAt.Equal(startedAt) { + t.Errorf("job = %+v found = %v", got, found) + } + }) + + t.Run("a ready job survives a restart while its binary exists", func(t *testing.T) { + dir := t.TempDir() + binary := filepath.Join(dir, "renewcms_staged") + if err := os.WriteFile(binary, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "update-job.json") + _ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: startedAt, StagedBinary: binary}) + + status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true) + + if status.State != update.StateReady { + t.Errorf("state = %q", status.State) + } + }) + + t.Run("a downloading record at startup reads as failed download", func(t *testing.T) { + for _, state := range []string{update.StateDownloading, update.StateVerifying} { + path := filepath.Join(t.TempDir(), "update-job.json") + _ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: state, StartedAt: startedAt, DownloadedBytes: 4, TotalBytes: 9}) + + status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true) + + if status.State != update.StateFailed || status.Error == nil || *status.Error != "download" || status.Progress != nil { + t.Errorf("%s: status = %+v", state, status) + } + } + }) + + t.Run("a ready record whose binary is gone reads as idle", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "update-job.json") + _ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: startedAt, StagedBinary: filepath.Join(t.TempDir(), "missing")}) + + status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true) + + if status.State != update.StateIdle || status.StartedAt != nil { + t.Errorf("status = %+v", status) + } + }) + + t.Run("a missing or corrupt file reads as idle", func(t *testing.T) { + missing := filepath.Join(t.TempDir(), "update-job.json") + if _, found := infraUpdate.NewJSONJobStore(missing).Load(); found { + t.Error("a missing file must read as no job") + } + corrupt := filepath.Join(t.TempDir(), "update-job.json") + if err := os.WriteFile(corrupt, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if _, found := infraUpdate.NewJSONJobStore(corrupt).Load(); found { + t.Error("a corrupt file must read as no job") + } + }) +} + +func TestFileHelperDetector(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "update-helper.json") + detector := infraUpdate.NewFileHelperDetector(path) + + if detector.Installed() { + t.Error("an absent status file means no helper") + } + if err := os.WriteFile(path, []byte("not json"), 0o600); err != nil { + t.Fatal(err) + } + if detector.Installed() { + t.Error("a corrupt status file means no helper") + } + if err := os.WriteFile(path, []byte(`{"version":"1"}`), 0o600); err != nil { + t.Fatal(err) + } + if !detector.Installed() { + t.Error("a valid status file means the helper is installed") + } +} + +func TestSQLiteDatabaseBackup(t *testing.T) { + db := newBackupTestDatabase(t) + backups := filepath.Join(t.TempDir(), "backups") + clock := time.Date(2026, 9, 30, 10, 0, 0, 0, time.UTC) + backup := infraUpdate.NewSQLiteDatabaseBackup(db, backups, func() time.Time { + clock = clock.Add(time.Second) + return clock + }) + + t.Run("the copy holds the same rows", func(t *testing.T) { + path, err := backup.Backup() + if err != nil { + t.Fatal(err) + } + if got := countBackupRows(t, path); got != 2 { + t.Errorf("rows in the copy = %d, want 2", got) + } + }) + + t.Run("only the three newest copies remain", func(t *testing.T) { + existing, _ := filepath.Glob(filepath.Join(backups, "*.db")) + for _, path := range existing { + backdate(t, path, 10*time.Hour) + } + var newest []string + for range 4 { + path, err := backup.Backup() + if err != nil { + t.Fatal(err) + } + newest = append(newest, path) + for index, earlier := range newest[:len(newest)-1] { + backdate(t, earlier, time.Duration(len(newest)-1-index)*time.Hour) + } + } + entries, err := os.ReadDir(backups) + if err != nil || len(entries) != 3 { + t.Fatalf("entries = %d, err = %v", len(entries), err) + } + for _, path := range newest[1:] { + if _, err := os.Stat(path); err != nil { + t.Errorf("a newer copy was pruned: %v", err) + } + } + }) + + t.Run("a failed copy prunes nothing", func(t *testing.T) { + directory := t.TempDir() + fixed := func() time.Time { return clock } + failing := infraUpdate.NewSQLiteDatabaseBackup(db, directory, fixed) + if _, err := failing.Backup(); err != nil { + t.Fatal(err) + } + for _, name := range []string{"a.db", "b.db", "c.db"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + } + + if _, err := failing.Backup(); err == nil { + t.Fatal("copying onto an existing file must fail") + } + + entries, _ := os.ReadDir(directory) + if len(entries) != 4 { + t.Errorf("entries = %d, want the 4 earlier files untouched", len(entries)) + } + }) +} + +func newBackupTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + path := filepath.Join(t.TempDir(), "live.db") + db, err := gorm.Open(sqlite.Open(api.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + if err := db.AutoMigrate(&models.Article{}); err != nil { + t.Fatal(err) + } + for _, title := range []string{"First article", "Second article"} { + if err := db.Create(&models.Article{Title: title}).Error; err != nil { + t.Fatal(err) + } + } + return db +} + +func countBackupRows(t *testing.T, path string) int64 { + t.Helper() + copyDb, err := gorm.Open(sqlite.Open(path+"?mode=ro"), &gorm.Config{Logger: logger.Discard}) + if err != nil { + t.Fatal(err) + } + defer func() { + if sqlDB, err := copyDb.DB(); err == nil { + _ = sqlDB.Close() + } + }() + var count int64 + if err := copyDb.Model(&models.Article{}).Count(&count).Error; err != nil { + t.Fatal(err) + } + return count +} + +func backdate(t *testing.T, path string, age time.Duration) { + t.Helper() + modified := time.Now().Add(-age) + if err := os.Chtimes(path, modified, modified); err != nil { + if os.IsNotExist(err) { + return + } + t.Fatal(err) + } +} + +func TestSQLiteDatabaseBackupStaleCopyHeldOpen(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("an open file can only block deletion on windows") + } + db := newBackupTestDatabase(t) + directory := t.TempDir() + locked := filepath.Join(directory, "locked.db") + for _, name := range []string{"locked.db", "b.db", "c.db"} { + if err := os.WriteFile(filepath.Join(directory, name), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + } + backdate(t, locked, 10*time.Hour) + held, err := os.Open(locked) + if err != nil { + t.Fatal(err) + } + defer held.Close() + backup := infraUpdate.NewSQLiteDatabaseBackup(db, directory, time.Now) + + if _, err := backup.Backup(); err == nil { + t.Error("a stale copy that cannot be removed must surface as an error") + } +} + +func TestStartUpdateDownloadUseCaseProgressCadence(t *testing.T) { + rig := newDownloadRigFor(t, update.Platform{OS: "linux", Arch: "amd64"}, []byte(strings.Repeat("b", 1000))) + rig.fetcher.during = func(name string, report update.ProgressFunc) { + if name != rig.assetName { + return + } + for _, done := range []int64{10, 15, 999, 1000} { + report(done, 1000) + } + } + + if _, err := rig.start.Start(); err != nil { + t.Fatal(err) + } + rig.waitForJob(update.StateReady, update.StateFailed) + + want := []int64{0, 10, 999, 1000, 1000} + got := rig.jobs.downloadedBytes() + if fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("saved download progress = %v, want %v", got, want) + } +} + +func TestStartUpdateDownloadUseCaseAcceptsAnUndeclaredSize(t *testing.T) { + rig := newDownloadRig(t) + rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.Size = 0 }) + + if _, err := rig.start.Start(); err != nil { + t.Fatal(err) + } + + if job := rig.waitForJob(update.StateReady, update.StateFailed); job.State != update.StateReady { + t.Errorf("job = %+v, log: %s", job, rig.logger.text()) + } +} + +func TestStartUpdateDownloadUseCaseReportsAFailedRecordSave(t *testing.T) { + const saveFailure = "update job record could not be saved" + + t.Run("a record that cannot be saved is logged", func(t *testing.T) { + rig := newDownloadRig(t) + rig.fetcher.errors[rig.assetName] = errDownloadNetwork + rig.fetcher.during = func(name string, _ update.ProgressFunc) { + if name == rig.assetName { + rig.jobs.mu.Lock() + rig.jobs.saveErr = errRepositoryFailure + rig.jobs.mu.Unlock() + } + } + + if _, err := rig.start.Start(); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(rig.logger.text(), saveFailure) && time.Now().Before(deadline) { + time.Sleep(2 * time.Millisecond) + } + if !strings.Contains(rig.logger.text(), saveFailure) { + t.Errorf("log = %s", rig.logger.text()) + } + }) + + t.Run("a saved failure is not reported as a save error", func(t *testing.T) { + rig := newDownloadRig(t) + rig.fetcher.errors[rig.assetName] = errDownloadNetwork + + if _, err := rig.start.Start(); err != nil { + t.Fatal(err) + } + rig.waitForJob(update.StateFailed) + + if strings.Contains(rig.logger.text(), saveFailure) { + t.Errorf("log = %s", rig.logger.text()) + } + }) +} + +func TestSQLiteDatabaseBackupKeepsTheNewestNameOnEqualTimes(t *testing.T) { + directory := t.TempDir() + moment := time.Now().Add(-time.Hour) + for _, name := range []string{"renewcms-1.db", "renewcms-2.db", "renewcms-3.db", "renewcms-4.db"} { + path := filepath.Join(directory, name) + if err := os.WriteFile(path, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, moment, moment); err != nil { + t.Fatal(err) + } + } + backup := infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), directory, time.Now) + + if _, err := backup.Backup(); err != nil { + t.Fatal(err) + } + + for name, kept := range map[string]bool{"renewcms-1.db": false, "renewcms-2.db": false, "renewcms-3.db": true, "renewcms-4.db": true} { + _, err := os.Stat(filepath.Join(directory, name)) + if kept != (err == nil) { + t.Errorf("%s kept = %v, want %v", name, err == nil, kept) + } + } +} diff --git a/test/update_http_test.go b/test/update_http_test.go index 31daed1..72dba01 100644 --- a/test/update_http_test.go +++ b/test/update_http_test.go @@ -20,11 +20,12 @@ import ( ) const ( - updateOwnerId = 1 - updateAdminId = 2 - updateUnverifiedId = 3 - updateSecondOwner = 4 - updateRunning = "v2.2.0-beta" + updateOwnerId = 1 + updateAdminId = 2 + updateUnverifiedId = 3 + updateSecondOwner = 4 + updateUnverifiedOwner = 5 + updateRunning = "v2.2.0-beta" ) var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "latest", "notes", "progress", "release_url", "started_at", "state"} @@ -36,14 +37,20 @@ type updateHarness struct { store *fakeUpdateStore } -func newUpdateHarness(t *testing.T, running string) *updateHarness { - t.Helper() - validator.InitValidator() +func newUpdateUserRepo() *mockUserRepo { users := newMockUserRepo() users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2} users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin} users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, Role: user.RoleAdmin} users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, Role: user.RoleOwner} + users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, Role: user.RoleOwner} + return users +} + +func newUpdateHarness(t *testing.T, running string) *updateHarness { + t.Helper() + validator.InitValidator() + users := newUpdateUserRepo() feed := &fakeReleaseFeed{release: update.Release{ Tag: "v2.3.0-beta", URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta", @@ -56,7 +63,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness { container := &api.UseCases{ Token: token, GetUserUseCase: useCases.NewGetUserUseCase(users), - GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, running), + GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running), CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now), } return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store} @@ -306,9 +313,9 @@ func TestUpdateCheckHTTP(t *testing.T) { } }) - t.Run("download and restart are not mounted", func(t *testing.T) { + t.Run("restart is not mounted", func(t *testing.T) { h := newUpdateHarness(t, updateRunning) - for _, path := range []string{"/update/download", "/update/restart"} { + for _, path := range []string{"/update/restart"} { if rec := h.sendAs(t, http.MethodPost, path, updateOwnerId); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed { t.Errorf("%s status = %d", path, rec.Code) } diff --git a/test/update_test.go b/test/update_test.go index d483f7c..4f4fd4b 100644 --- a/test/update_test.go +++ b/test/update_test.go @@ -154,7 +154,7 @@ func TestReleaseNotesStripInlineMarkdown(t *testing.T) { func TestGetUpdateStatusUseCase(t *testing.T) { t.Run("never checked", func(t *testing.T) { - status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, "v2.2.0-beta").GetStatus() + status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true) if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available { t.Errorf("status = %+v", status) @@ -170,7 +170,7 @@ func TestGetUpdateStatusUseCase(t *testing.T) { t.Run("stored result", func(t *testing.T) { store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true} - status := useCases.NewGetUpdateStatusUseCase(store, "v2.2.0-beta").GetStatus() + status := useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true) if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle { t.Errorf("status = %+v", status) @@ -185,7 +185,7 @@ func TestGetUpdateStatusUseCase(t *testing.T) { t.Run("dev build never reports an update", func(t *testing.T) { store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true} - if useCases.NewGetUpdateStatusUseCase(store, "dev").GetStatus().Available { + if useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "dev").GetStatus(true).Available { t.Error("a dev build must not report an update") } }) diff --git a/web/src/components/UpdateBanner.vue b/web/src/components/UpdateBanner.vue index bdb1e76..ad16858 100644 --- a/web/src/components/UpdateBanner.vue +++ b/web/src/components/UpdateBanner.vue @@ -1,11 +1,29 @@ + + diff --git a/web/src/components/UpdateStatusBlock.vue b/web/src/components/UpdateStatusBlock.vue index e309cd9..2578903 100644 --- a/web/src/components/UpdateStatusBlock.vue +++ b/web/src/components/UpdateStatusBlock.vue @@ -17,6 +17,7 @@ const props = defineProps<{ title: string text: string meta?: string + live?: 'polite' | 'off' }>() const toneClasses = computed(() => TONE_CLASSES[props.tone]) @@ -24,7 +25,7 @@ const spinning = computed(() => props.icon === 'i-lucide-loader-circle')