feat: signed releases, release feed, notification (read-only)

This commit is contained in:
fsylvain committed 2026-10-01 15:01:53 +02:00
1 parent a49eec909c
commit 834493a4f5
49 files changed
+4191 -18

No files matched your search

+1
View File
@@ -14,3 +14,4 @@ CORS_ALLOWED_ORIGINS=
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
TRUSTED_PROXIES=
UPDATE_FEED_URL=https://git.floriansylvain.fr
+25 -2
View File
@@ -122,14 +122,37 @@ tasks:
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
release:
desc: Cross-compile production binaries for all release targets → bin/
deps: [build:web]
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
cmds:
- task: release:check
- task: build:web
- for: { var: RELEASE_TARGETS }
task: build:target
vars:
GOOS: '{{index (splitList "/" .ITEM) 0}}'
GOARCH: '{{index (splitList "/" .ITEM) 1}}'
- go run ./cmd/releasesign checksums bin --match "{{.BINARY}}_{{.VERSION}}_*"
- go run ./cmd/releasesign sign bin/checksums.txt
- go run ./cmd/releasesign verify bin/checksums.txt
release:check:
internal: true
cmds:
- cmd: 'test -n "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY is not set. Point it at the private release key file (see task release:keygen)." >&2; exit 1; }'
silent: true
- cmd: 'test -f "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY points to a missing file: $RELEASE_SIGNING_KEY" >&2; exit 1; }'
silent: true
- mkdir -p tmp
- echo probe > tmp/release-probe.txt
- defer: rm -f tmp/release-probe.txt tmp/release-probe.txt.sig
- go run ./cmd/releasesign sign tmp/release-probe.txt
release:keygen:
desc: One-time release key pair. Usage, task release:keygen -- <private key path outside the repository>
cmds:
- cmd: 'test -n "{{.CLI_ARGS}}" || { echo "usage: task release:keygen -- <private key path outside the repository>" >&2; exit 1; }'
silent: true
- go run ./cmd/releasesign keygen {{.CLI_ARGS}}
build:target:
internal: true
+15 -1
View File
@@ -5,16 +5,19 @@ import (
domainImage "RenewCMS/internal/domain/image"
domainInvite "RenewCMS/internal/domain/invite"
domainMail "RenewCMS/internal/domain/mail"
domainUpdate "RenewCMS/internal/domain/update"
domainUser "RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/config"
"RenewCMS/internal/infrastructure/hashing"
"RenewCMS/internal/infrastructure/mailer"
"RenewCMS/internal/infrastructure/persistence/models"
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
"RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"fmt"
"os"
"path/filepath"
"time"
"github.com/glebarez/sqlite"
"github.com/go-chi/jwtauth/v5"
@@ -51,6 +54,8 @@ type UseCases struct {
MergeMediaUseCase *useCases.MergeMediaUseCase
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
GetSettingsUseCase *useCases.GetSettingsUseCase
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
}
type Repositories struct {
@@ -59,9 +64,14 @@ type Repositories struct {
ImageRepo domainImage.Repository
InviteRepo domainInvite.Repository
MailRepo domainMail.Repository
ReleaseFeed domainUpdate.ReleaseFeed
UpdateStore domainUpdate.StatusStore
}
const sqliteBusyTimeoutMs = 5000
const (
sqliteBusyTimeoutMs = 5000
updateStatusFileName = "update-status.json"
)
func SQLiteDSN(dbName string) string {
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
@@ -98,6 +108,8 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories {
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)),
}
}
@@ -134,6 +146,8 @@ 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),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now),
}
}
+39
View File
@@ -0,0 +1,39 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
)
type Handler struct {
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
}
func NewHandler(getStatus *useCases.GetUpdateStatusUseCase, checkForUpdate *useCases.CheckForUpdateUseCase) *Handler {
return &Handler{GetUpdateStatusUseCase: getStatus, CheckForUpdateUseCase: checkForUpdate}
}
func (h *Handler) GetStatus(w http.ResponseWriter, _ *http.Request) {
writeStatus(w, h.GetUpdateStatusUseCase.GetStatus())
}
func (h *Handler) Check(w http.ResponseWriter, _ *http.Request) {
status, err := h.CheckForUpdateUseCase.Check()
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
writeStatus(w, status)
}
func writeStatus(w http.ResponseWriter, status domain.UpdateStatus) {
body, err := json.Marshal(status)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
_, _ = w.Write(body)
}
+15 -1
View File
@@ -2,6 +2,7 @@ package middleware
import (
"RenewCMS/api/pkg/apperr"
"RenewCMS/api/pkg/claims"
"net/http"
"strconv"
"sync"
@@ -14,12 +15,25 @@ type rateLimitWindow struct {
}
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr })
}
func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
return newKeyedRateLimiter(maxAttempts, window, userRateLimitKey)
}
func userRateLimitKey(r *http.Request) string {
userId, _ := claims.UserId(r)
return strconv.FormatUint(uint64(userId), 10)
}
func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
var mu sync.Mutex
windows := make(map[string]*rateLimitWindow)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
retryAfter, allowed := recordAttempt(&mu, windows, r.RemoteAddr, maxAttempts, window)
retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window)
if !allowed {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
+26
View File
@@ -7,11 +7,13 @@ import (
"RenewCMS/api/handlers/media"
"RenewCMS/api/handlers/settings"
"RenewCMS/api/handlers/staticimages"
"RenewCMS/api/handlers/update"
"RenewCMS/api/handlers/user"
"RenewCMS/api/middleware"
"RenewCMS/internal/infrastructure/config"
"encoding/json"
"net/http"
"time"
"github.com/MadAppGang/httplog"
"github.com/go-chi/chi/v5"
@@ -129,6 +131,29 @@ func NewSettingsRouter(container *UseCases) http.Handler {
return r
}
const (
updateCheckMaxAttempts = 5
updateCheckWindow = time.Minute
)
func NewSystemRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase)
r.Use(jwtauth.Verifier(container.Token))
r.Use(jwtauth.Authenticator(container.Token))
r.Use(middleware.RequireCurrentToken(container.GetUserUseCase))
r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase))
r.Get("/update", updateHandler.GetStatus)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireOwner(container.GetUserUseCase))
r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow))
r.Post("/update/check", updateHandler.Check)
})
return r
}
func NewMediaRouter(container *UseCases) http.Handler {
r := chi.NewRouter()
@@ -162,6 +187,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux {
r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
r.Mount("/settings", NewSettingsRouter(container))
r.Mount("/media", NewMediaRouter(container))
r.Mount("/system", NewSystemRouter(container))
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
return r
+10
View File
@@ -0,0 +1,10 @@
package main
import (
"RenewCMS/internal/infrastructure/update/releasesign"
"os"
)
func main() {
os.Exit(releasesign.Run(os.Args[1:], os.Stdout, os.Stderr))
}
+1
View File
@@ -47,6 +47,7 @@ var envDefaults = map[string]string{
"AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5",
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
"TRUSTED_PROXIES": "",
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
}
func main() {
+1
View File
@@ -30,6 +30,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required
| `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. |
| `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. |
| `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). |
| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. |
Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)).
+14
View File
@@ -19,8 +19,22 @@ task build # this OS/arch → bin/renewcms[.exe]
task release # all targets → bin/renewcms_<version>_<os>_<arch>[.exe]
```
`task release` also signs the release: it writes `bin/checksums.txt` (SHA-256 of the six binaries) and signs it into `bin/checksums.txt.sig` with an Ed25519 key. It refuses to start, before building anything, unless `RELEASE_SIGNING_KEY` points to the private key file and that key matches the public key committed as `internal/infrastructure/update/release.pub`. The one-time setup is below.
The frontend is built once and embedded into every binary. The binaries are static (`CGO_ENABLED=0`), so a Linux build runs on any distribution. The version comes from `git describe`: tag a release (`git tag v1.2.0`) before building, and the binary logs its version at startup.
### Release signing key (maintainers, once)
1. Run `task release:keygen -- <path>` with a path outside the repository (for example `~/.renewcms/release.key`). The private key is written there and never leaves your machine; keygen refuses a path inside the work tree.
2. Commit the printed public key as `internal/infrastructure/update/release.pub`. Until you do, the file holds a placeholder (32 zero bytes) and `task release` fails with a message saying so.
3. Export `RELEASE_SIGNING_KEY=<path to the private key>` before every `task release`.
To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, with `checksums.txt.sig` next to it, then compare the binary's hash with the matching line of `checksums.txt`.
### Update checks
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.
## 2. Lay out the server
The following is a suggested layout for Linux:
+1 -1
View File
@@ -2,7 +2,7 @@
- **Route:** `/settings`
- **API:** GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings: Updates", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle).
Opened from the sidebar (Settings, settings icon, after Integration) and from the mobile drawer. Every verified user sees it. It holds two sections: Version (everyone) and Updates (the owner has the buttons, admins read the status).
+1 -1
View File
@@ -17,7 +17,7 @@ Rules: bullets are full sentences ending with a period; user-facing wording, no
## Published releases
Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create --tag <tag> --title "<name>" --note-file docs/releases/<tag>.md --prerelease --asset <binary>` (one `--asset` per binary), after `task release`.
Each body lives in `docs/releases/<tag>.md`. Publish with `tea releases create --tag <tag> --title "<name>" --note-file docs/releases/<tag>.md --prerelease --asset <file>` after `task release`, with one `--asset` per file: the six binaries, `bin/checksums.txt` and `bin/checksums.txt.sig`. `task release` produces all eight and fails before building when the signing key is missing or wrong (see [deployment.md](deployment.md#release-signing-key-maintainers-once)). Never publish a release without the two checksum files.
| Tag | Name |
| --- | --- |
+292
View File
@@ -0,0 +1,292 @@
Feature: Updates part 1: signed releases, release feed and read-only update notification
As the owner of a RenewCMS site
I want to see when a newer signed release exists, and have admins see the same status read-only
So that I know when to update, while downloading and restarting arrive in later slices
Background:
Given slices 1 to 3 of Updates ship in one release, so no released build shows the command
"renewcms enable-browser-updates" before it exists (it is implemented in slice 3)
And every release is published as a Gitea prerelease with the six binaries, checksums.txt and checksums.txt.sig
And cmd/releasesign is a stdlib-only tool (crypto/ed25519, no new dependency) with the commands
keygen <private-key-path> [--pub-out <path>], sign <file> [--key <path>] [--pub <path>],
verify <file> [--pub <path>]
And keygen writes the private key to the path given and refuses a path inside the repository work tree
And sign and verify use the embedded public key unless --pub is given; tests always pass --pub with a throwaway key
And sign refuses to sign when the private key does not match the public key, naming the mismatch
And the public key is internal/infrastructure/update/release.pub, embedded with go:embed
And until the user runs task release:keygen and commits the real public key, release.pub holds the placeholder:
the base64 of 32 zero bytes (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=); an all-zero key is recognised
as the placeholder, and sign and verify refuse it with a clear message
And the real private key is generated by the user outside the repo; the coder never generates or commits it
And task release writes checksums.txt (SHA-256 of every binary) next to the binaries and signs it into
checksums.txt.sig with the private key at the path in the env var RELEASE_SIGNING_KEY
And task release fails clearly, before building anything, when RELEASE_SIGNING_KEY is unset, the file is missing,
the embedded key is the placeholder, or the private key does not match the embedded public key
And task release:keygen creates the one-time key pair and prints where to commit the public key
And docs/release-notes.md gains the publish step (attach checksums.txt and checksums.txt.sig with the six binaries),
docs/deployment.md and docs/configuration.md are updated, and nothing is published
And the domain package internal/domain/update (stdlib only) holds Release, ReleaseNotes, version comparison,
UpdateStatus, the ReleaseFeed interface, the StatusStore interface and the Err sentinels
And the Gitea adapter lives under internal/infrastructure/update and implements update.ReleaseFeed with
var _ update.ReleaseFeed = &X{}; it requests GET {UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases
(the list, not "latest")
And UPDATE_FEED_URL is in requiredEnvVars/envDefaults, .env.example and docs/configuration.md, defaulting to the repo base URL
And a JSON file store implements update.StatusStore and keeps the last result and checked_at in update-status.json
beside DB_FILE, shared by all owners and surviving a restart; no new env var is added
And GetUpdateStatusUseCase and CheckForUpdateUseCase depend only on the domain interfaces; handlers in
api/handlers/update receive them through NewHandler; api/dependecyInjection.go builds the adapters
And NewSystemRouter is mounted at /v1/system behind jwtauth.Verifier, jwtauth.Authenticator, RequireCurrentToken
and RequireVerifiedUser; POST /update/check also sits behind RequireOwner and a limiter of 5 checks per minute
per user_id (constants, not env vars, separate from the auth limiter) that answers 429 with an integer Retry-After
And GET /v1/system/update returns 200 with JSON of exactly these snake_case keys: current, latest, available,
release_url, notes {version, released_at, items}, checked_at, can_apply, state, progress, error, started_at
And release_url is the release's html_url, null when never checked; in slice 3 the "How to update" link is
repointed at the "Browser updates" section of docs/deployment.md
And in this slice can_apply is always false and state is always "idle"; never-checked values are latest "",
available false, release_url null, notes null, checked_at null, progress null, error null, started_at null
And versions compare numerically per segment, a final release beats a beta of the same number, and the
running build "dev" never reports an update
And release notes are the release body converted to a plain-text list of items
And the API returns raw tags; the SPA shows the newer release without its leading "v" ("Version 2.3.0-beta")
and the running version exactly as reported
And the download and restart endpoints are not mounted; ReleaseFeed, StatusStore and UpdateStatus are shaped so
slices 2 and 3 only add use cases
And errors are plain text from http.Error
Scenario Outline: Version comparison
Given the running version is "<current>" and the newest feed release is "<latest>"
When the versions are compared
Then available is <available>
Examples:
| current | latest | available |
| v2.2.0-beta | v2.3.0-beta | true |
| v2.5.0-beta | v2.5.0 | true |
| v2.5.0 | v2.5.0-beta | false |
| v2.9.0 | v2.10.0 | true |
| v2.5.0-beta | v2.5.0-beta | false |
| v2.5.0 | v2.4.9 | false |
| dev | v9.9.9 | false |
Scenario: The newest release is chosen from the list regardless of feed order
Given the feed list holds v2.4.0-beta, v2.5.0-beta and v2.3.0-beta, all marked prerelease
When the feed is read
Then the latest release is v2.5.0-beta
Scenario: Drafts are ignored
Given the feed list holds a draft release v9.0.0 and a published v2.5.0-beta
When the feed is read
Then the latest release is v2.5.0-beta
Scenario: Release notes become a plain-text list
Given a release body "## What's new\n- **Media** library\n- Fixed [caching](http://x) bug\n\n* Faster start"
When the notes are built
Then notes.items is ["Media library","Fixed caching bug","Faster start"]
And notes.released_at is the release's published date
Scenario: GET returns the idle shape, never applicable
Given a verified user, running version "v2.2.0-beta", and a stored result with latest "v2.3.0-beta",
html_url "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta" and checked_at 2026-09-30T09:41:00Z
When the client sends GET /v1/system/update
Then the status is 200 and the body has exactly the 12 keys
And current is "v2.2.0-beta", latest is "v2.3.0-beta", available is true, can_apply is false, state is "idle"
And release_url is the stored html_url and checked_at is "2026-09-30T09:41:00Z"
And the feed is not contacted
Scenario: GET before any check
Given no stored result
When a verified user sends GET /v1/system/update
Then the status is 200, available is false, latest is "", release_url, notes and checked_at are null, state is "idle"
Scenario Outline: GET is open to any verified user
Given the caller is <role> with a valid jwt cookie
When the client sends GET /v1/system/update
Then the status is 200
Examples:
| role |
| owner |
| admin |
Scenario: GET without a cookie
When the client sends GET /v1/system/update with no jwt cookie
Then the status is 401
Scenario: GET with a stale token
Given the jwt cookie was minted with a token version older than the account's
When the client sends GET /v1/system/update
Then the status is 401 and the body is apperr.UnauthorizedErrorMessage
Scenario: GET by an unverified account
When an unverified user with a valid cookie sends GET /v1/system/update
Then the status is 403 and the body is apperr.VerificationRequiredErrorMessage
Scenario: The owner checks and the result is stored
Given the fake feed returns v2.3.0-beta and the running version is "v2.2.0-beta"
When the owner sends POST /v1/system/update/check
Then the status is 200 with the same body shape as GET and available true
And the fake store holds latest "v2.3.0-beta" and checked_at at the check time
And a later GET returns the same result without contacting the feed
Scenario: A check with nothing newer
Given the feed returns v2.2.0-beta and the running version is "v2.2.0-beta"
When the owner sends POST /v1/system/update/check
Then the status is 200 and available is false
Scenario: A dev build never reports an update
Given the running version is "dev" and the feed returns v9.9.9
When the owner sends POST /v1/system/update/check
Then the status is 200 and available is false
Scenario: An admin cannot check
Given the caller is an admin
When the client sends POST /v1/system/update/check
Then the status is 403 and the body is apperr.OwnerRequiredErrorMessage
And the feed is not contacted
Scenario: Feed failure keeps the last result
Given the store holds latest "v2.3.0-beta" checked at 2026-09-30T09:41:00Z and the feed returns an error
When the owner sends POST /v1/system/update/check
Then the status is 500
And the store still holds the previous result and checked_at
And no feed error detail appears in the body
Scenario: Check is rate limited per user
When the owner sends 6 checks within one minute
Then the first 5 answer 200 and the 6th answers 429 with apperr.TooManyRequestsErrorMessage and an integer Retry-After
And the feed is contacted 5 times
And a different owner is not limited by the first owner's checks
And the auth limiter's counters are untouched
Scenario: The stored result survives a restart
Given the JSON file store saved a result
When a new store is opened on the same file
Then the same latest, release_url and checked_at are returned
And a missing or corrupt file reads as "no result" and is not fatal
Scenario: Release signing round trip with a throwaway key
Given a key pair made by keygen at a path outside the repo
When sign is run on checksums.txt with that key and verify with its public key
Then verify exits 0
And after one byte of checksums.txt changes, verify exits non-zero with a clear message
And verify with a different public key exits non-zero
Scenario: keygen refuses a path inside the repository
When keygen is given a path inside the work tree
Then it exits non-zero, writes nothing and says why
Scenario: The placeholder public key is refused
Given release.pub holds the placeholder (32 zero bytes, base64)
When sign or verify uses the embedded key
Then it exits non-zero with a message that the placeholder key has not been replaced
Scenario: sign refuses a private key that does not match the public key
Given a private key whose public half differs from the public key in use
When sign is run
Then it exits non-zero naming the mismatch and writes no .sig file
Scenario Outline: task release refuses to sign
Given <condition>
When task release is run
Then it fails before building any binary with a message naming <what>
And nothing is published
Examples:
| condition | what |
| RELEASE_SIGNING_KEY is unset | RELEASE_SIGNING_KEY |
| RELEASE_SIGNING_KEY points to a missing file | the missing path |
| the embedded public key is the placeholder | the placeholder key |
| the private key does not match the public key | the key mismatch |
Scenario: Settings, owner, Up to date (route /settings)
Given the session role is "owner" and GET /v1/system/update resolves available false with checked_at set
When the owner opens /settings
Then the Updates card shows the status block with icon circle-check (success), title "You're up to date",
text "You have the latest version of RenewCMS." and a meta line "Last checked ..." built from checked_at
And the only button is "Check for updates" (refresh-cw, secondary)
And no Restart button, hint or progress is rendered
Scenario: Settings, owner, Checking
When the owner presses "Check for updates"
Then the block shows icon loader-circle, "Checking for updates…" / "This usually takes a few seconds."
in a polite live region
And the button reads "Checking…" and is disabled
And POST /v1/system/update/check is sent once
Scenario: Settings, owner, Check failed
Given POST /v1/system/update/check answers 500 or never reaches the server
Then the block shows icon wifi-off (warning), "Couldn't check for updates" /
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected."
and the meta "Last checked ..." from the kept result
And the only button is "Try again" (rotate-cw, primary)
Scenario: Settings, owner, newer version, not prepared
Given the session role is "owner" and GET /v1/system/update resolves available true, can_apply false, latest "v2.3.0-beta"
When the owner opens /settings
Then the block shows icon info, "Version 2.3.0-beta is available" /
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you."
And "For whoever manages the server" shows the one-line code "renewcms enable-browser-updates" with a Copy button
And the note "They run this once on the server. After that, Update and Restart appear here." shows
And the link "How to update" (arrow-up-right) opens release_url in a new tab
And no button other than Copy is rendered: not Update, not Restart, not even Check for updates
Scenario: Settings, owner, newer version, notes open
Given the same state with notes.items and notes.released_at 2026-09-24
When the owner activates "What's new in 2.3.0-beta" (a button with aria-expanded false then true)
Then "Released 24 Sep 2026" and the plain-text list show, and activating it again collapses them
And the notes are collapsed by default
Scenario: Settings, command copied
When the owner presses Copy
Then the button reads "Copied" with a check for 2 s, is announced, then reads "Copy" again
Scenario: Settings, admin view
Given the session role is "admin"
When GET /v1/system/update resolves available true with latest "v2.3.0-beta"
Then the card 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
When it resolves available false
Then the card shows "You're up to date" / "Only the owner can update or restart the site." and no button
Scenario: Settings, Role changed (403)
Given an owner is on /settings and POST /v1/system/update/check 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 GET /v1/system/update and renders the admin view
Scenario: Settings, Too many requests (429)
Given POST /v1/system/update/check 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", counts down each second and is re-enabled at zero
Scenario: A check runs on the first open after 24 hours, for the owner only
Given the owner opens /settings and checked_at is null or older than 24 hours
Then POST /v1/system/update/check is sent once automatically and the Checking state shows
When checked_at is under 24 hours old
Then no automatic check is sent
And an admin never sends one
Scenario: Home banner for the owner, update available
Given the owner is on /home and available is true with latest "v2.3.0-beta"
Then a region labelled "Update available" shows above the content in info tone with icon circle-arrow-up,
title "Version 2.3.0-beta is available",
text "Update it from Settings. It takes about 2 minutes and the site stays online.",
a "Go to Settings" button and a dismiss button
When the owner dismisses it
Then the banner is hidden and localStorage remembers the dismissed version
And it shows again when latest changes to a newer version
Scenario: Settings sidebar pill and mobile dot
Given the owner and available is true
Then the Settings sidebar item shows an "Update" pill, and on mobile the menu icon shows a dot read as "Update available"
And the pill stays after the banner is dismissed
Scenario: Admins and an up-to-date owner see no banner, pill or dot
Then an admin, or an owner with available false, sees no banner, pill or dot
Scenario: Pending states of slices 2 and 3
Then these states are listed as pending in the visual gate, never passing: Update available with its Update button
(and its notes-open frame), Downloading, Verifying, Ready to install, Restart confirm, Restart request failed,
Restarting, Restarting taking longer, Update done, Rolled back, Download failed, Verification failed,
Plain restart confirm, restarting and done, Update already running (409), and the Home "ready to install" banner
And the Update, Restart and progress elements are not rendered in any state of this slice
+105
View File
@@ -0,0 +1,105 @@
# QA: Updates part 1 (signed releases, release feed, notification)
## Automated Go (`task test`, package `test`, black-box)
Files: `test/update_test.go` (domain, use cases), `test/update_http_test.go` (HTTP), `test/update_feed_test.go`
(Gitea adapter, JSON store), `test/releasesign_test.go` (signing tool). Fakes with unique names: `fakeReleaseFeed`
(returns a list or an error, counts calls), `fakeUpdateStore`; reuse `mockUserRepo` from `test/user_test.go`.
HTTP setup: `validator.InitValidator()`; `token := jwtauth.New("HS256", []byte("test-secret"), nil)`; mount a fresh
`chi.NewRouter()` at `/v1/system` mirroring `NewSystemRouter` (Verifier, Authenticator, RequireCurrentToken,
RequireVerifiedUser; the check route also RequireOwner and the per-user limiter, 5 per minute); cookies via
`token.Encode({"user_id","token_version"})` as in `test/stale_token_invalidation_http_test.go`. `httptest` only: no live
server, no SQLite, no SMTP.
1. Version comparison, table-driven over the Examples in the feature (domain; `errors.Is` for sentinels).
2. Feed adapter against `httptest.NewServer` serving a Gitea JSON list: path is
`/api/v1/repos/fsylvain/RenewCMS/releases`; newest by numeric order regardless of order; drafts skipped; final beats
beta; notes markdown converted to items; `html_url` carried; a 500 and invalid JSON return an error.
3. `GET /v1/system/update` as owner and as admin: 200, exactly the 12 keys, `can_apply` false, `state` "idle", values
from the stored result, `release_url` from the stored `html_url`; the fake feed call count is 0.
4. GET before any check: `latest` "", `available` false, `release_url`, `notes`, `checked_at`, `progress`, `error`,
`started_at` null.
5. GET: no cookie 401; stale token 401 with `apperr.UnauthorizedErrorMessage`; unverified 403 with
`apperr.VerificationRequiredErrorMessage`.
6. `POST /v1/system/update/check` as owner: 200, store updated with latest, `release_url` and `checked_at`, feed called
once, a later GET equals it. Nothing newer: `available` false. Running version "dev": `available` false.
7. POST check as admin: 403, body `apperr.OwnerRequiredErrorMessage+"\n"`, feed not called.
8. Feed error: 500, store unchanged (latest and `checked_at` as before), body contains no feed error text.
9. Rate limit: owner A sends 6 checks: first 5 are 200, the 6th is 429 with `apperr.TooManyRequestsErrorMessage+"\n"`
and an integer `Retry-After` of at least 1; feed called 5 times; owner B's first check still 200; a login-limiter
instance built in the same test is unaffected.
10. JSON file store in `t.TempDir()`: save, reopen, same values; missing file and corrupt file read as empty without error.
11. Signing tool, run through `go run ./cmd/releasesign` (or its exported package) with a throwaway key pair generated
in `t.TempDir()` at test time, always with `--pub`; never read the committed `release.pub` key except in case 13:
keygen creates the key; sign then verify passes; one flipped byte fails verify; a different public key fails verify;
keygen into a path inside the repo tree is refused and writes nothing; sign with a private key that does not match
`--pub` fails, naming the mismatch, and writes no `.sig`.
12. Placeholder: a `--pub` file holding base64 of 32 zero bytes makes sign and verify fail with the placeholder message.
13. The committed `internal/infrastructure/update/release.pub` is checked only for shape: a valid base64 string that
decodes to 32 bytes. Do not assert that it is or is not the placeholder, since the user replaces it later.
14. Review items, not Go tests: `internal/domain/update` imports only the stdlib; use cases import no `net/http`,
`gorm`, adapter or `config`; the handler imports no adapter; `UPDATE_FEED_URL` is in `requiredEnvVars`/`envDefaults`,
`.env.example` and `docs/configuration.md`; no private key or secret value is in the repo.
## Automated SPA (Vitest + Vue Test Utils, `task test:web`)
Mock `useApi.request` for `GET /v1/system/update` and `POST /v1/system/update/check`. Fake timers for the countdown and
the 2 s "Copied". No test freezes the system date and none asserts a relative date or "Last checked today". Reuse the
board's sample data: running `v2.2.0-beta`, latest `v2.3.0-beta`, released 24 Sep 2026, items from the frame's script.
One test per state:
1. Up to date (owner): "You're up to date", only "Check for updates" rendered.
2. Checking: text, polite live region, "Checking…" disabled, one POST.
3. Check failed (500, then `NetworkError`): copy, only "Try again", last result kept.
4. Not prepared (available, `can_apply` false): copy, command line, note, "How to update" link with `href` equal to
`release_url` and an external target; no button other than Copy.
5. Notes open: collapsed by default, `aria-expanded` toggles, "Released 24 Sep 2026" and the plain-text list.
6. Command copied: Copy writes the command to the clipboard mock, reads "Copied" for 2 s, then "Copy".
7. Admin view: both texts, no button; no auto check.
8. Role changed (403): toast "Only the owner can do this" / "Ask the owner of this site to update RenewCMS.", then a
reload of the GET and the admin view.
9. 429: "Too many tries", button "Try again in 0:42" counting down to enabled.
10. Auto check: sent once when `checked_at` is null or older than 24 h, not when fresh, never for admin.
11. Home banner: shown for the owner when available, copy and "Go to Settings" navigates to /settings, dismiss writes
the version to localStorage and hides it, same version stays hidden after remount, a newer `latest` shows it again,
absent for admin and when not available.
12. Sidebar pill and mobile dot: owner and available only, pill remains after the banner is dismissed; absent for admin.
13. No Update, Restart or progressbar element exists in any of the states above; the shown version drops the leading
"v" ("Version 2.3.0-beta") while the Version card keeps `v2.2.0-beta`.
## 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` using the board's own sample data. Exact values from
`docs/design/source/AppFrame.dc.html` (Settings and Home blocks); layout as drawn, not from the prose.
- [ ] Settings: Checking (mock a never-resolving check after the click setup), Not prepared, Admin view (available),
Too many requests (429, Retry-After 42).
- [ ] Settings, `skip: 'clock-sensitive'` (the meta line is relative: "Last checked today at 09:41", "yesterday at 18:12"):
Up to date, Check failed, Admin view (up to date).
- [ ] Settings, Not prepared: command copied (the "Copied" state is time-bound; Vitest only), listed as not visual.
- [ ] Home: Update available (owner), with the sidebar pill and the mobile dot as drawn.
- [ ] Pending, never passing (slices 2 and 3): Update available with its Update button, Update available with notes
open (its frame draws the Update button), Downloading, Verifying, Ready to install, Restart confirm, Restart
request failed, Restarting, Restarting taking longer, Update done, Rolled back, Download failed, Verification
failed, Plain restart (confirm, restarting, done), Update already running (409), Home "ready to install" banner.
- [ ] Not covered by screenshots: keyboard and focus order, focus ring on the "Updates" heading (`id="updates-h"`),
hover and motion, the Copy announcement, the dismiss button focus.
## Manual verification
- Replace the placeholder key: run `task release:keygen`, store the private key outside the repo, set
`RELEASE_SIGNING_KEY` to its path, commit the printed public key as `internal/infrastructure/update/release.pub`, and
rebuild. Never commit or paste the private key. Until this is done, `task release` must fail with the placeholder message.
- Run `task release` with `RELEASE_SIGNING_KEY` unset, pointing to a missing file, and pointing to a key that does not
match `release.pub`: each fails before any binary is built, with a message naming the cause. With the correct key,
`checksums.txt` and `checksums.txt.sig` appear next to the six binaries and `cmd/releasesign verify` passes on them.
- Against the real Gitea feed (default `UPDATE_FEED_URL`), run a dev API with a lower fake version and press "Check for
updates" in the SPA: it reports the newest release; the "How to update" link opens that release's page.
- Real release notes (the markdown body) render as a sensible plain-text list.
- Restart the API and confirm the last result and `checked_at` survive, in `update-status.json` beside `DB_FILE`.
- Embedded SPA build (no `-tags nofrontend`) serves /settings and /home with the card, banner and pill.
- Confirm `docs/release-notes.md` (publish step: attach `checksums.txt` and `checksums.txt.sig` with the six binaries),
`docs/deployment.md` and `docs/configuration.md` are updated, and that nothing was published.
- Release gate: slices 1 to 3 ship together, so no released build shows `renewcms enable-browser-updates` before slice 3
implements it.
+105
View File
@@ -0,0 +1,105 @@
package update
import (
"errors"
"time"
)
const StateIdle = "idle"
var (
ErrNoRelease = errors.New("no release found")
ErrInvalidVersion = errors.New("invalid version")
ErrFeedUnavailable = errors.New("release feed unavailable")
)
type ReleaseNotes struct {
Version string `json:"version"`
ReleasedAt time.Time `json:"released_at"`
Items []string `json:"items"`
}
type Release struct {
Tag string
URL string
PublishedAt time.Time
Notes ReleaseNotes
}
type CheckResult struct {
Latest string `json:"latest"`
ReleaseURL string `json:"release_url"`
Notes ReleaseNotes `json:"notes"`
CheckedAt time.Time `json:"checked_at"`
}
type Progress struct {
Percent int `json:"percent"`
DownloadedBytes int64 `json:"downloaded_bytes"`
TotalBytes int64 `json:"total_bytes"`
}
type UpdateStatus struct {
Current string `json:"current"`
Latest string `json:"latest"`
Available bool `json:"available"`
ReleaseURL *string `json:"release_url"`
Notes *ReleaseNotes `json:"notes"`
CheckedAt *time.Time `json:"checked_at"`
CanApply bool `json:"can_apply"`
State string `json:"state"`
Progress *Progress `json:"progress"`
Error *string `json:"error"`
StartedAt *time.Time `json:"started_at"`
}
func NewResult(release Release, checkedAt time.Time) CheckResult {
return CheckResult{
Latest: release.Tag,
ReleaseURL: release.URL,
Notes: release.Notes,
CheckedAt: checkedAt.UTC(),
}
}
func NewStatus(current string, result *CheckResult) UpdateStatus {
status := UpdateStatus{Current: current, State: StateIdle}
if result == nil {
return status
}
releaseURL := result.ReleaseURL
notes := result.Notes
notes.Items = nonNilItems(notes.Items)
checkedAt := result.CheckedAt
status.Latest = result.Latest
status.Available = IsNewer(current, result.Latest)
status.ReleaseURL = &releaseURL
status.Notes = &notes
status.CheckedAt = &checkedAt
return status
}
func nonNilItems(items []string) []string {
if items == nil {
return []string{}
}
return items
}
func Newest(releases []Release) (Release, error) {
var newest Release
found := false
for _, release := range releases {
if _, err := ParseVersion(release.Tag); err != nil {
continue
}
if !found || IsNewer(newest.Tag, release.Tag) {
newest = release
found = true
}
}
if !found {
return Release{}, ErrNoRelease
}
return newest, nil
}
+37
View File
@@ -0,0 +1,37 @@
package update
import (
"regexp"
"strings"
"time"
)
var (
bulletLine = regexp.MustCompile(`^[-*+]\s+(.+)$`)
markdownLink = regexp.MustCompile(`!?\[([^\]]*)\]\([^)]*\)`)
boldText = regexp.MustCompile(`\*\*([^*]+)\*\*|__([^_]+)__`)
italicText = regexp.MustCompile(`\*([^*\s][^*]*)\*`)
inlineCode = regexp.MustCompile("`([^`]*)`")
)
func BuildNotes(tag string, publishedAt time.Time, body string) ReleaseNotes {
items := []string{}
for _, line := range strings.Split(body, "\n") {
match := bulletLine.FindStringSubmatch(strings.TrimSpace(line))
if match == nil {
continue
}
if item := plainText(match[1]); item != "" {
items = append(items, item)
}
}
return ReleaseNotes{Version: tag, ReleasedAt: publishedAt, Items: items}
}
func plainText(markdown string) string {
text := markdownLink.ReplaceAllString(markdown, "$1")
text = boldText.ReplaceAllString(text, "$1$2")
text = italicText.ReplaceAllString(text, "$1")
text = inlineCode.ReplaceAllString(text, "$1")
return strings.TrimSpace(text)
}
@@ -0,0 +1,10 @@
package update
type ReleaseFeed interface {
Latest() (Release, error)
}
type StatusStore interface {
Load() (CheckResult, bool)
Save(result CheckResult) error
}
+101
View File
@@ -0,0 +1,101 @@
package update
import (
"cmp"
"strconv"
"strings"
)
type Version struct {
numbers []int
pre []string
}
func ParseVersion(raw string) (Version, error) {
trimmed := strings.TrimPrefix(strings.TrimSpace(raw), "v")
core, pre, hasPre := strings.Cut(trimmed, "-")
if core == "" || (hasPre && pre == "") {
return Version{}, ErrInvalidVersion
}
var version Version
for _, segment := range strings.Split(core, ".") {
number, err := strconv.Atoi(segment)
if err != nil || number < 0 {
return Version{}, ErrInvalidVersion
}
version.numbers = append(version.numbers, number)
}
if hasPre {
version.pre = strings.Split(pre, ".")
}
return version, nil
}
func IsNewer(current, latest string) bool {
currentVersion, err := ParseVersion(current)
if err != nil {
return false
}
latestVersion, err := ParseVersion(latest)
if err != nil {
return false
}
return compareVersions(latestVersion, currentVersion) > 0
}
func compareVersions(a, b Version) int {
if order := compareNumbers(a.numbers, b.numbers); order != 0 {
return order
}
return comparePreRelease(a.pre, b.pre)
}
func compareNumbers(a, b []int) int {
for i := range max(len(a), len(b)) {
if order := cmp.Compare(segmentAt(a, i), segmentAt(b, i)); order != 0 {
return order
}
}
return 0
}
func segmentAt(numbers []int, index int) int {
if index < len(numbers) {
return numbers[index]
}
return 0
}
func comparePreRelease(a, b []string) int {
if len(a) == 0 && len(b) == 0 {
return 0
}
if len(a) == 0 {
return 1
}
if len(b) == 0 {
return -1
}
length := min(len(a), len(b))
for i := 0; i < length; i++ {
if order := compareIdentifiers(a[i], b[i]); order != 0 {
return order
}
}
return cmp.Compare(len(a), len(b))
}
func compareIdentifiers(a, b string) int {
numberA, errA := strconv.Atoi(a)
numberB, errB := strconv.Atoi(b)
if errA == nil && errB == nil {
return cmp.Compare(numberA, numberB)
}
if errA == nil {
return -1
}
if errB == nil {
return 1
}
return strings.Compare(a, b)
}
@@ -32,6 +32,7 @@ type Settings struct {
AuthRateLimit AuthRateLimitSettings
TrustedProxies []string
Version string
UpdateFeedURL string
}
func (s Settings) IsProduction() bool {
@@ -74,6 +75,7 @@ func NewSettingsFromEnv() (Settings, error) {
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
},
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
}, nil
}
@@ -0,0 +1,79 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const (
releasesPath = "/api/v1/repos/fsylvain/RenewCMS/releases"
feedRequestTimeout = 10 * time.Second
maxFeedBodyBytes = 4 << 20
)
type GiteaReleaseFeed struct {
baseURL string
client *http.Client
}
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"`
}
func NewGiteaReleaseFeed(baseURL string) *GiteaReleaseFeed {
return &GiteaReleaseFeed{
baseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{Timeout: feedRequestTimeout},
}
}
func (g *GiteaReleaseFeed) Latest() (domain.Release, error) {
releases, err := g.fetch()
if err != nil {
return domain.Release{}, err
}
return domain.Newest(releases)
}
func (g *GiteaReleaseFeed) fetch() ([]domain.Release, error) {
response, err := g.client.Get(g.baseURL + releasesPath)
if err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrFeedUnavailable, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w: status %d", domain.ErrFeedUnavailable, response.StatusCode)
}
var raw []giteaRelease
if err := json.NewDecoder(io.LimitReader(response.Body, maxFeedBodyBytes)).Decode(&raw); err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrFeedUnavailable, err)
}
return toReleases(raw), nil
}
func toReleases(raw []giteaRelease) []domain.Release {
releases := make([]domain.Release, 0, len(raw))
for _, item := range raw {
if item.Draft {
continue
}
releases = append(releases, domain.Release{
Tag: item.TagName,
URL: item.HTMLURL,
PublishedAt: item.PublishedAt,
Notes: domain.BuildNotes(item.TagName, item.PublishedAt, item.Body),
})
}
return releases
}
@@ -0,0 +1,55 @@
package update
import (
domain "RenewCMS/internal/domain/update"
"encoding/json"
"os"
"path/filepath"
"sync"
)
const statusFilePermissions = 0o600
type JSONStatusStore struct {
path string
mu sync.Mutex
}
var _ domain.StatusStore = &JSONStatusStore{}
func NewJSONStatusStore(path string) *JSONStatusStore {
return &JSONStatusStore{path: path}
}
func (s *JSONStatusStore) Load() (domain.CheckResult, bool) {
s.mu.Lock()
defer s.mu.Unlock()
content, err := os.ReadFile(s.path)
if err != nil {
return domain.CheckResult{}, false
}
var result domain.CheckResult
if err := json.Unmarshal(content, &result); err != nil || result.CheckedAt.IsZero() {
return domain.CheckResult{}, false
}
return result, true
}
func (s *JSONStatusStore) Save(result domain.CheckResult) error {
s.mu.Lock()
defer s.mu.Unlock()
content, err := json.Marshal(result)
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)
}
@@ -0,0 +1,38 @@
package update
import (
"bytes"
"crypto/ed25519"
_ "embed"
"encoding/base64"
"errors"
"strings"
)
//go:embed release.pub
var embeddedPublicKey string
var (
ErrPublicKeyFormat = errors.New("the public key is not a base64 encoded 32 byte ed25519 key")
ErrPlaceholderPublicKey = errors.New("the placeholder public key has not been replaced: run task release:keygen and commit the real key as internal/infrastructure/update/release.pub")
)
func EmbeddedPublicKey() (ed25519.PublicKey, error) {
return ParsePublicKey(embeddedPublicKey)
}
func ParsePublicKey(text string) (ed25519.PublicKey, error) {
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(text))
if err != nil || len(decoded) != ed25519.PublicKeySize {
return nil, ErrPublicKeyFormat
}
return ed25519.PublicKey(decoded), nil
}
func IsPlaceholderPublicKey(key ed25519.PublicKey) bool {
return bytes.Equal(key, make([]byte, ed25519.PublicKeySize))
}
func EncodePublicKey(key ed25519.PublicKey) string {
return base64.StdEncoding.EncodeToString(key)
}
@@ -0,0 +1 @@
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
@@ -0,0 +1,292 @@
package releasesign
import (
"RenewCMS/internal/infrastructure/update"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
const (
exitOK = 0
exitFailure = 1
exitUsage = 2
signingKeyEnvVar = "RELEASE_SIGNING_KEY"
signatureSuffix = ".sig"
privateKeyFileMode = 0o600
publicFileMode = 0o644
repositoryMarker = ".git"
checksumsFileName = "checksums.txt"
usageText = "usage: releasesign keygen <private-key-path> [--pub-out <path>] | sign <file> [--key <path>] [--pub <path>] | verify <file> [--pub <path>] | checksums <dir> [--match <glob>]"
publicKeyCommitHint = "commit the public key as internal/infrastructure/update/release.pub"
)
var (
errInsideRepository = errors.New("the private key path is inside the repository work tree, choose a path outside it")
errKeyMismatch = errors.New("the private key does not match the public key in use")
errPrivateKeyFormat = errors.New("the private key file is not a base64 encoded ed25519 private key")
errSignatureFormat = errors.New("the signature file is not a base64 encoded ed25519 signature")
errBadSignature = errors.New("the signature does not match the file: it was changed or signed with another key")
errNoKeyPath = errors.New("no private key given: pass --key or set RELEASE_SIGNING_KEY")
)
func Run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprintln(stderr, usageText)
return exitUsage
}
commands := map[string]func([]string, io.Writer) error{
"keygen": keygen,
"sign": sign,
"verify": verify,
"checksums": checksums,
}
command, known := commands[args[0]]
if !known {
fmt.Fprintln(stderr, usageText)
return exitUsage
}
if err := command(args[1:], stdout); err != nil {
fmt.Fprintln(stderr, "releasesign:", err)
return exitFailure
}
return exitOK
}
func parseArguments(name string, args []string, define func(*flag.FlagSet)) (string, error) {
set := flag.NewFlagSet(name, flag.ContinueOnError)
set.SetOutput(io.Discard)
define(set)
positional, flags := splitArguments(args)
if err := set.Parse(flags); err != nil {
return "", err
}
if len(positional) != 1 {
return "", errors.New(usageText)
}
return positional[0], nil
}
func splitArguments(args []string) ([]string, []string) {
var positional, flags []string
for i := 0; i < len(args); i++ {
if strings.HasPrefix(args[i], "--") {
flags = append(flags, args[i])
if !strings.Contains(args[i], "=") && i+1 < len(args) {
i++
flags = append(flags, args[i])
}
continue
}
positional = append(positional, args[i])
}
return positional, flags
}
func keygen(args []string, stdout io.Writer) error {
var pubOut string
keyPath, err := parseArguments("keygen", args, func(set *flag.FlagSet) { set.StringVar(&pubOut, "pub-out", "", "") })
if err != nil {
return err
}
if err := refuseInsideRepository(keyPath); err != nil {
return err
}
if _, err := os.Stat(keyPath); err == nil {
return fmt.Errorf("%s already exists, it is not overwritten", keyPath)
}
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
encodedPrivate := base64.StdEncoding.EncodeToString(privateKey) + "\n"
if err := os.WriteFile(keyPath, []byte(encodedPrivate), privateKeyFileMode); err != nil {
return err
}
encodedPublic := update.EncodePublicKey(publicKey)
if pubOut != "" {
if err := os.WriteFile(pubOut, []byte(encodedPublic+"\n"), publicFileMode); err != nil {
return err
}
}
fmt.Fprintf(stdout, "private key written to %s: keep it outside the repository and never share it\n", keyPath)
fmt.Fprintf(stdout, "public key: %s\n%s\n", encodedPublic, publicKeyCommitHint)
return nil
}
func refuseInsideRepository(path string) error {
absolute, err := filepath.Abs(path)
if err != nil {
return err
}
for directory := filepath.Dir(absolute); ; directory = filepath.Dir(directory) {
if _, err := os.Stat(filepath.Join(directory, repositoryMarker)); err == nil {
return fmt.Errorf("%w (%s)", errInsideRepository, directory)
}
if directory == filepath.Dir(directory) {
return nil
}
}
}
func sign(args []string, stdout io.Writer) error {
var keyPath, pubPath string
file, err := parseArguments("sign", args, func(set *flag.FlagSet) {
set.StringVar(&keyPath, "key", "", "")
set.StringVar(&pubPath, "pub", "", "")
})
if err != nil {
return err
}
publicKey, err := loadPublicKey(pubPath)
if err != nil {
return err
}
privateKey, err := loadPrivateKey(keyPath)
if err != nil {
return err
}
if !publicKeyOf(privateKey).Equal(publicKey) {
return errKeyMismatch
}
content, err := os.ReadFile(file)
if err != nil {
return err
}
signature := base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, content))
if err := os.WriteFile(file+signatureSuffix, []byte(signature+"\n"), publicFileMode); err != nil {
return err
}
fmt.Fprintf(stdout, "signed %s into %s\n", file, file+signatureSuffix)
return nil
}
func verify(args []string, stdout io.Writer) error {
var pubPath string
file, err := parseArguments("verify", args, func(set *flag.FlagSet) { set.StringVar(&pubPath, "pub", "", "") })
if err != nil {
return err
}
publicKey, err := loadPublicKey(pubPath)
if err != nil {
return err
}
content, err := os.ReadFile(file)
if err != nil {
return err
}
rawSignature, err := os.ReadFile(file + signatureSuffix)
if err != nil {
return err
}
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(rawSignature)))
if err != nil || len(signature) != ed25519.SignatureSize {
return errSignatureFormat
}
if !ed25519.Verify(publicKey, content, signature) {
return errBadSignature
}
fmt.Fprintf(stdout, "%s is signed by the release key\n", file)
return nil
}
func checksums(args []string, stdout io.Writer) error {
match := "*"
dir, err := parseArguments("checksums", args, func(set *flag.FlagSet) { set.StringVar(&match, "match", "*", "") })
if err != nil {
return err
}
paths, err := filepath.Glob(filepath.Join(dir, match))
if err != nil {
return err
}
sort.Strings(paths)
var lines strings.Builder
for _, path := range paths {
line, err := checksumLine(path)
if err != nil {
return err
}
lines.WriteString(line)
}
if lines.Len() == 0 {
return fmt.Errorf("no file in %s matches %s", dir, match)
}
target := filepath.Join(dir, checksumsFileName)
if err := os.WriteFile(target, []byte(lines.String()), publicFileMode); err != nil {
return err
}
fmt.Fprintln(stdout, "wrote", target)
return nil
}
func checksumLine(path string) (string, error) {
name := filepath.Base(path)
if name == checksumsFileName || strings.HasSuffix(name, signatureSuffix) {
return "", nil
}
info, err := os.Stat(path)
if err != nil || info.IsDir() {
return "", err
}
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
sum := sha256.Sum256(content)
return fmt.Sprintf("%s %s\n", hex.EncodeToString(sum[:]), name), nil
}
func loadPublicKey(pubPath string) (ed25519.PublicKey, error) {
var key ed25519.PublicKey
var err error
if pubPath == "" {
key, err = update.EmbeddedPublicKey()
} else {
var content []byte
content, err = os.ReadFile(pubPath)
if err == nil {
key, err = update.ParsePublicKey(string(content))
}
}
if err != nil {
return nil, err
}
if update.IsPlaceholderPublicKey(key) {
return nil, update.ErrPlaceholderPublicKey
}
return key, nil
}
func loadPrivateKey(keyPath string) (ed25519.PrivateKey, error) {
if keyPath == "" {
keyPath = os.Getenv(signingKeyEnvVar)
}
if keyPath == "" {
return nil, errNoKeyPath
}
content, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
}
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(content)))
if err != nil || len(decoded) != ed25519.PrivateKeySize {
return nil, errPrivateKeyFormat
}
return ed25519.PrivateKey(decoded), nil
}
func publicKeyOf(privateKey ed25519.PrivateKey) ed25519.PublicKey {
return privateKey.Public().(ed25519.PublicKey)
}
@@ -0,0 +1,29 @@
package useCases
import (
"RenewCMS/internal/domain/update"
"time"
)
type CheckForUpdateUseCase struct {
feed update.ReleaseFeed
store update.StatusStore
currentVersion string
now func() time.Time
}
func NewCheckForUpdateUseCase(feed update.ReleaseFeed, store update.StatusStore, currentVersion string, now func() time.Time) *CheckForUpdateUseCase {
return &CheckForUpdateUseCase{feed: feed, store: store, currentVersion: currentVersion, now: now}
}
func (c *CheckForUpdateUseCase) Check() (update.UpdateStatus, error) {
release, err := c.feed.Latest()
if err != nil {
return update.UpdateStatus{}, err
}
result := update.NewResult(release, c.now())
if err := c.store.Save(result); err != nil {
return update.UpdateStatus{}, err
}
return update.NewStatus(c.currentVersion, &result), nil
}
@@ -0,0 +1,20 @@
package useCases
import "RenewCMS/internal/domain/update"
type GetUpdateStatusUseCase struct {
store update.StatusStore
currentVersion string
}
func NewGetUpdateStatusUseCase(store update.StatusStore, currentVersion string) *GetUpdateStatusUseCase {
return &GetUpdateStatusUseCase{store: store, currentVersion: currentVersion}
}
func (g *GetUpdateStatusUseCase) GetStatus() update.UpdateStatus {
result, found := g.store.Load()
if !found {
return update.NewStatus(g.currentVersion, nil)
}
return update.NewStatus(g.currentVersion, &result)
}
+312
View File
@@ -0,0 +1,312 @@
package test
import (
"RenewCMS/internal/infrastructure/update/releasesign"
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
const placeholderPublicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
type signingKeyPair struct {
privatePath string
publicPath string
}
func runReleaseSign(args ...string) (int, string) {
var stdout, stderr bytes.Buffer
code := releasesign.Run(args, &stdout, &stderr)
return code, stdout.String() + stderr.String()
}
func newSigningKeyPair(t *testing.T, dir string) signingKeyPair {
t.Helper()
pair := signingKeyPair{privatePath: filepath.Join(dir, "release.key"), publicPath: filepath.Join(dir, "release.pub")}
if code, output := runReleaseSign("keygen", pair.privatePath, "--pub-out", pair.publicPath); code != 0 {
t.Fatalf("keygen failed: %s", output)
}
return pair
}
func writeChecksums(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "checksums.txt")
if err := os.WriteFile(path, []byte("abc renewcms_v1_linux_amd64\ndef renewcms_v1_darwin_arm64\n"), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestReleaseSigningRoundTrip(t *testing.T) {
t.Run("Release signing round trip with a throwaway key", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
file := writeChecksums(t, dir)
if code, output := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath); code != 0 {
t.Fatalf("sign failed: %s", output)
}
if code, output := runReleaseSign("verify", file, "--pub", pair.publicPath); code != 0 {
t.Fatalf("verify failed: %s", output)
}
content, _ := os.ReadFile(file)
content[0] ^= 0x01
if err := os.WriteFile(file, content, 0o600); err != nil {
t.Fatal(err)
}
code, output := runReleaseSign("verify", file, "--pub", pair.publicPath)
if code == 0 || !strings.Contains(output, "signature does not match") {
t.Errorf("a changed file must fail verify with a clear message: %d %q", code, output)
}
})
t.Run("verify with a different public key fails", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
other := newSigningKeyPair(t, t.TempDir())
file := writeChecksums(t, dir)
runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath)
if code, _ := runReleaseSign("verify", file, "--pub", other.publicPath); code == 0 {
t.Error("verify with another public key must fail")
}
})
t.Run("sign takes the private key from RELEASE_SIGNING_KEY", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
file := writeChecksums(t, dir)
t.Setenv("RELEASE_SIGNING_KEY", pair.privatePath)
if code, output := runReleaseSign("sign", file, "--pub", pair.publicPath); code != 0 {
t.Fatalf("sign failed: %s", output)
}
if code, output := runReleaseSign("verify", file, "--pub", pair.publicPath); code != 0 {
t.Fatalf("verify failed: %s", output)
}
})
t.Run("sign without any key says so", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
t.Setenv("RELEASE_SIGNING_KEY", "")
code, output := runReleaseSign("sign", writeChecksums(t, dir), "--pub", pair.publicPath)
if code == 0 || !strings.Contains(output, "RELEASE_SIGNING_KEY") {
t.Errorf("code = %d output = %q", code, output)
}
})
t.Run("sign with a missing key file names the path", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
missing := filepath.Join(dir, "missing.key")
code, output := runReleaseSign("sign", writeChecksums(t, dir), "--key", missing, "--pub", pair.publicPath)
if code == 0 || !strings.Contains(output, missing) {
t.Errorf("code = %d output = %q", code, output)
}
})
}
func TestReleaseSignKeygen(t *testing.T) {
t.Run("keygen refuses a path inside the repository", func(t *testing.T) {
repoRoot, err := filepath.Abs("..")
if err != nil {
t.Fatal(err)
}
inside := filepath.Join(repoRoot, "tmp", "releasesign-test.key")
t.Cleanup(func() { os.Remove(inside) })
code, output := runReleaseSign("keygen", inside)
if code == 0 || !strings.Contains(output, "inside the repository") {
t.Errorf("code = %d output = %q", code, output)
}
if _, err := os.Stat(inside); err == nil {
t.Error("nothing may be written inside the repository")
}
})
t.Run("keygen never overwrites an existing key", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
before, _ := os.ReadFile(pair.privatePath)
code, _ := runReleaseSign("keygen", pair.privatePath)
after, _ := os.ReadFile(pair.privatePath)
if code == 0 || !bytes.Equal(before, after) {
t.Errorf("code = %d, key changed = %v", code, !bytes.Equal(before, after))
}
})
t.Run("keygen prints the public key and where to commit it", func(t *testing.T) {
dir := t.TempDir()
code, output := runReleaseSign("keygen", filepath.Join(dir, "k"))
if code != 0 || !strings.Contains(output, "internal/infrastructure/update/release.pub") {
t.Errorf("code = %d output = %q", code, output)
}
})
}
func TestReleaseSignPlaceholderKey(t *testing.T) {
t.Run("The placeholder public key is refused", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
placeholder := filepath.Join(dir, "placeholder.pub")
if err := os.WriteFile(placeholder, []byte(placeholderPublicKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
file := writeChecksums(t, dir)
signCode, signOutput := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", placeholder)
verifyCode, verifyOutput := runReleaseSign("verify", file, "--pub", placeholder)
for _, output := range []string{signOutput, verifyOutput} {
if !strings.Contains(output, "placeholder public key has not been replaced") {
t.Errorf("output = %q", output)
}
}
if signCode == 0 || verifyCode == 0 {
t.Errorf("sign = %d verify = %d", signCode, verifyCode)
}
if _, err := os.Stat(file + ".sig"); err == nil {
t.Error("no .sig may be written")
}
})
t.Run("a malformed public key is refused", func(t *testing.T) {
dir := t.TempDir()
bad := filepath.Join(dir, "bad.pub")
_ = os.WriteFile(bad, []byte("not base64!"), 0o600)
if code, _ := runReleaseSign("verify", writeChecksums(t, dir), "--pub", bad); code == 0 {
t.Error("a malformed key must fail")
}
})
}
func TestReleaseSignKeyMismatch(t *testing.T) {
t.Run("sign refuses a private key that does not match the public key", func(t *testing.T) {
dir := t.TempDir()
signer := newSigningKeyPair(t, dir)
other := newSigningKeyPair(t, t.TempDir())
file := writeChecksums(t, dir)
code, output := runReleaseSign("sign", file, "--key", signer.privatePath, "--pub", other.publicPath)
if code == 0 || !strings.Contains(output, "does not match") {
t.Errorf("code = %d output = %q", code, output)
}
if _, err := os.Stat(file + ".sig"); err == nil {
t.Error("no .sig may be written")
}
})
}
func TestReleaseSignUsage(t *testing.T) {
for name, args := range map[string][]string{
"no command": nil,
"unknown command": {"explode"},
"missing file": {"verify"},
"unknown flag": {"verify", "x", "--nope", "1"},
} {
t.Run(name, func(t *testing.T) {
if code, _ := runReleaseSign(args...); code == 0 {
t.Error("expected a non-zero exit")
}
})
}
}
func TestReleaseSignChecksums(t *testing.T) {
dir := t.TempDir()
for name, content := range map[string]string{
"renewcms_v2.5.0_linux_amd64": "linux",
"renewcms_v2.5.0_windows_amd64.exe": "windows",
"renewcms_v2.4.0_linux_amd64": "old",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
t.Run("writes the SHA-256 of the matching binaries only", func(t *testing.T) {
code, output := runReleaseSign("checksums", dir, "--match", "renewcms_v2.5.0_*")
if code != 0 {
t.Fatalf("checksums failed: %s", output)
}
content, err := os.ReadFile(filepath.Join(dir, "checksums.txt"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
if len(lines) != 2 || !strings.HasSuffix(lines[0], " renewcms_v2.5.0_linux_amd64") || !strings.HasSuffix(lines[1], " renewcms_v2.5.0_windows_amd64.exe") {
t.Fatalf("checksums.txt = %q", content)
}
sum := sha256.Sum256([]byte("linux"))
if want := hex.EncodeToString(sum[:]) + " renewcms_v2.5.0_linux_amd64"; lines[0] != want {
t.Errorf("line = %q, want %q", lines[0], want)
}
})
t.Run("a second run does not list checksums.txt or its signature", func(t *testing.T) {
runReleaseSign("checksums", dir, "--match", "*")
content, _ := os.ReadFile(filepath.Join(dir, "checksums.txt"))
if strings.Contains(string(content), "checksums.txt") {
t.Errorf("checksums.txt lists itself: %q", content)
}
})
t.Run("no match is an error", func(t *testing.T) {
if code, _ := runReleaseSign("checksums", dir, "--match", "nothing_*"); code == 0 {
t.Error("expected a non-zero exit")
}
})
}
func TestReleaseSignFailsOnUnwritableOutputs(t *testing.T) {
t.Run("keygen reports a public key that cannot be written", func(t *testing.T) {
dir := t.TempDir()
code, _ := runReleaseSign("keygen", filepath.Join(dir, "release.key"), "--pub-out", filepath.Join(dir, "missing", "release.pub"))
if code == 0 {
t.Fatal("keygen exited 0 although the public key could not be written")
}
})
t.Run("keygen reports a trailing flag with no value without panicking", func(t *testing.T) {
code, _ := runReleaseSign("keygen", filepath.Join(t.TempDir(), "release.key"), "--pub-out")
if code == 0 {
t.Fatal("keygen exited 0 for a flag with no value")
}
})
t.Run("sign reports a signature that cannot be written", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
file := writeChecksums(t, dir)
if err := os.Mkdir(file+".sig", 0o700); err != nil {
t.Fatal(err)
}
if code, _ := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath); code == 0 {
t.Fatal("sign exited 0 although the signature could not be written")
}
})
t.Run("checksums reports a checksums file that cannot be written", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.bin"), []byte("a"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "checksums.txt"), 0o700); err != nil {
t.Fatal(err)
}
if code, _ := runReleaseSign("checksums", dir, "--match", "*.bin"); code == 0 {
t.Fatal("checksums exited 0 although checksums.txt could not be written")
}
})
}
+183
View File
@@ -0,0 +1,183 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"crypto/ed25519"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
const giteaReleasesPath = "/api/v1/repos/fsylvain/RenewCMS/releases"
func newGiteaFeedServer(t *testing.T, status int, body string) (*httptest.Server, *[]string) {
t.Helper()
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
return server, &paths
}
func TestGiteaReleaseFeed(t *testing.T) {
list := `[
{"tag_name":"v2.4.0-beta","html_url":"https://git.example/r/v2.4.0-beta","body":"- Four","draft":false,"prerelease":true,"published_at":"2026-08-01T10:00:00Z"},
{"tag_name":"v9.0.0","html_url":"https://git.example/r/v9.0.0","body":"- Draft","draft":true,"prerelease":false,"published_at":"2026-09-30T10:00:00Z"},
{"tag_name":"v2.5.0-beta","html_url":"https://git.example/r/v2.5.0-beta","body":"## New\n- **Media** library\n- Fixed [caching](http://x) bug","draft":false,"prerelease":true,"published_at":"2026-09-24T08:30:00Z"},
{"tag_name":"v2.3.0-beta","html_url":"https://git.example/r/v2.3.0-beta","body":"","draft":false,"prerelease":true,"published_at":"2026-07-01T10:00:00Z"}
]`
t.Run("requests the release list and returns the newest published release", func(t *testing.T) {
server, paths := newGiteaFeedServer(t, http.StatusOK, list)
release, err := infraUpdate.NewGiteaReleaseFeed(server.URL + "/").Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}
if !reflect.DeepEqual(*paths, []string{giteaReleasesPath}) {
t.Errorf("paths = %v", *paths)
}
if release.Tag != "v2.5.0-beta" || release.URL != "https://git.example/r/v2.5.0-beta" {
t.Errorf("release = %+v", release)
}
if want := []string{"Media library", "Fixed caching bug"}; !reflect.DeepEqual(release.Notes.Items, want) {
t.Errorf("items = %v", release.Notes.Items)
}
if want := time.Date(2026, 9, 24, 8, 30, 0, 0, time.UTC); !release.PublishedAt.Equal(want) || !release.Notes.ReleasedAt.Equal(want) {
t.Errorf("published = %v, notes released = %v", release.PublishedAt, release.Notes.ReleasedAt)
}
})
t.Run("a final release beats a beta of the same number", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, `[{"tag_name":"v2.6.0-beta","published_at":"2026-09-01T00:00:00Z"},{"tag_name":"v2.6.0","published_at":"2026-09-02T00:00:00Z"}]`)
release, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest()
if err != nil || release.Tag != "v2.6.0" {
t.Errorf("release = %+v, err = %v", release, err)
}
})
t.Run("only drafts is no release", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, `[{"tag_name":"v9.0.0","draft":true}]`)
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrNoRelease) {
t.Errorf("err = %v", err)
}
})
t.Run("a 500 is a feed error", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusInternalServerError, "boom")
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
})
t.Run("invalid json is a feed error", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, "not json")
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
})
t.Run("an unreachable server is a feed error", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, "[]")
url := server.URL
server.Close()
if _, err := infraUpdate.NewGiteaReleaseFeed(url).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
})
}
func TestJSONStatusStore(t *testing.T) {
t.Run("The stored result survives a restart", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "data", "update-status.json")
saved := newStoredUpdateResult()
if err := infraUpdate.NewJSONStatusStore(path).Save(saved); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, found := infraUpdate.NewJSONStatusStore(path).Load()
if !found {
t.Fatal("expected a stored result")
}
if loaded.Latest != saved.Latest || loaded.ReleaseURL != saved.ReleaseURL || !loaded.CheckedAt.Equal(saved.CheckedAt) {
t.Errorf("loaded = %+v", loaded)
}
if !reflect.DeepEqual(loaded.Notes.Items, saved.Notes.Items) {
t.Errorf("items = %v", loaded.Notes.Items)
}
})
t.Run("a second save replaces the first", func(t *testing.T) {
store := infraUpdate.NewJSONStatusStore(filepath.Join(t.TempDir(), "update-status.json"))
first := newStoredUpdateResult()
second := newStoredUpdateResult()
second.Latest = "v2.4.0-beta"
_ = store.Save(first)
_ = store.Save(second)
if loaded, _ := store.Load(); loaded.Latest != "v2.4.0-beta" {
t.Errorf("latest = %q", loaded.Latest)
}
})
t.Run("a missing file reads as no result", func(t *testing.T) {
if _, found := infraUpdate.NewJSONStatusStore(filepath.Join(t.TempDir(), "absent.json")).Load(); found {
t.Error("a missing file must read as no result")
}
})
t.Run("a corrupt file reads as no result", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "update-status.json")
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if _, found := infraUpdate.NewJSONStatusStore(path).Load(); found {
t.Error("a corrupt file must read as no result")
}
})
t.Run("an unwritable location is an error", func(t *testing.T) {
blocker := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := infraUpdate.NewJSONStatusStore(filepath.Join(blocker, "update-status.json")).Save(newStoredUpdateResult()); err == nil {
t.Error("expected an error")
}
})
}
func TestCommittedReleasePublicKeyHasTheRightShape(t *testing.T) {
content, err := os.ReadFile(filepath.Join("..", "internal", "infrastructure", "update", "release.pub"))
if err != nil {
t.Fatal(err)
}
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(content)))
if err != nil || len(decoded) != ed25519.PublicKeySize {
t.Fatalf("release.pub must be base64 of %d bytes: %v", ed25519.PublicKeySize, err)
}
if _, err := infraUpdate.EmbeddedPublicKey(); err != nil {
t.Errorf("embedded key: %v", err)
}
}
func TestGiteaReleaseFeedRejectsAnOversizedBody(t *testing.T) {
huge := `[{"tag_name":"v2.5.0","html_url":"u","body":"` + strings.Repeat("a", 5<<20) + `","draft":false,"published_at":"2026-09-24T08:30:00Z"}]`
server, _ := newGiteaFeedServer(t, http.StatusOK, huge)
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Fatalf("Latest error = %v, want ErrFeedUnavailable", err)
}
}
+317
View File
@@ -0,0 +1,317 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
)
const (
updateOwnerId = 1
updateAdminId = 2
updateUnverifiedId = 3
updateSecondOwner = 4
updateRunning = "v2.2.0-beta"
)
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "latest", "notes", "progress", "release_url", "started_at", "state"}
type updateHarness struct {
router http.Handler
token *jwtauth.JWTAuth
feed *fakeReleaseFeed
store *fakeUpdateStore
}
func newUpdateHarness(t *testing.T, running string) *updateHarness {
t.Helper()
validator.InitValidator()
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}
feed := &fakeReleaseFeed{release: update.Release{
Tag: "v2.3.0-beta",
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
}}
store := &fakeUpdateStore{}
token := jwtauth.New("HS256", []byte("test-secret"), nil)
now := func() time.Time { return updateTestCheckedAt }
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
}
func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, tokenVersion uint32) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if userId != 0 {
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec
}
func (h *updateHarness) sendAs(t *testing.T, method, path string, userId uint32) *httptest.ResponseRecorder {
tokenVersion := uint32(0)
if userId == updateOwnerId {
tokenVersion = 2
}
return h.send(t, method, path, userId, tokenVersion)
}
func decodeUpdateBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not json: %v: %s", err, rec.Body.String())
}
return body
}
func assertUpdateKeys(t *testing.T, body map[string]any) {
t.Helper()
keys := make([]string, 0, len(body))
for key := range body {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) != len(updateStatusKeys) {
t.Fatalf("keys = %v, want %v", keys, updateStatusKeys)
}
for i := range keys {
if keys[i] != updateStatusKeys[i] {
t.Fatalf("keys = %v, want %v", keys, updateStatusKeys)
}
}
}
func TestUpdateStatusHTTP(t *testing.T) {
t.Run("GET returns the idle shape, never applicable", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
h.store.result = newStoredUpdateResult()
h.store.found = true
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if body["current"] != updateRunning || body["latest"] != "v2.3.0-beta" || body["available"] != true || body["can_apply"] != false || body["state"] != "idle" {
t.Errorf("body = %v", body)
}
if body["release_url"] != h.store.result.ReleaseURL || body["checked_at"] != "2026-09-30T09:41:00Z" {
t.Errorf("release_url = %v, checked_at = %v", body["release_url"], body["checked_at"])
}
if body["progress"] != nil || body["error"] != nil || body["started_at"] != nil {
t.Errorf("progress, error and started_at must be null: %v", body)
}
notes := body["notes"].(map[string]any)
if notes["version"] != "v2.3.0-beta" || len(notes["items"].([]any)) != 1 || notes["released_at"] != "2026-09-24T08:00:00Z" {
t.Errorf("notes = %v", notes)
}
if h.feed.calls != 0 {
t.Errorf("feed calls = %d", h.feed.calls)
}
})
t.Run("GET before any check", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if rec.Code != http.StatusOK || body["available"] != false || body["latest"] != "" || body["state"] != "idle" {
t.Errorf("status = %d body = %v", rec.Code, body)
}
for _, key := range []string{"release_url", "notes", "checked_at", "progress", "error", "started_at"} {
if body[key] != nil {
t.Errorf("%s = %v, want null", key, body[key])
}
}
})
for name, userId := range map[string]uint32{"owner": updateOwnerId, "admin": updateAdminId} {
t.Run("GET is open to any verified user: "+name, func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", userId)
if rec.Code != http.StatusOK {
t.Errorf("status = %d", rec.Code)
}
})
}
t.Run("GET without a cookie", func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", 0, 0)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d", rec.Code)
}
})
t.Run("GET with a stale token", func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", updateOwnerId, 1)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
})
t.Run("GET by an unverified account", func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", updateUnverifiedId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
})
}
func TestUpdateCheckHTTP(t *testing.T) {
t.Run("The owner checks and the result is stored", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if body["available"] != true || body["latest"] != "v2.3.0-beta" || body["checked_at"] != "2026-09-30T09:41:00Z" {
t.Errorf("body = %v", body)
}
if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) {
t.Errorf("stored = %+v", h.store.result)
}
later := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
if later.Body.String() != rec.Body.String() {
t.Errorf("later GET = %s, check = %s", later.Body.String(), rec.Body.String())
}
if h.feed.calls != 1 {
t.Errorf("feed calls = %d", h.feed.calls)
}
})
t.Run("A check with nothing newer", func(t *testing.T) {
h := newUpdateHarness(t, "v2.3.0-beta")
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false {
t.Errorf("status = %d body = %s", rec.Code, rec.Body.String())
}
})
t.Run("A dev build never reports an update", func(t *testing.T) {
h := newUpdateHarness(t, "dev")
h.feed.release.Tag = "v9.9.9"
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false {
t.Errorf("status = %d body = %s", rec.Code, rec.Body.String())
}
})
t.Run("An admin cannot check", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
rec := h.sendAs(t, http.MethodPost, "/update/check", updateAdminId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if h.feed.calls != 0 {
t.Errorf("feed calls = %d", h.feed.calls)
}
})
t.Run("A check without a cookie is unauthorized", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
if rec := h.send(t, http.MethodPost, "/update/check", 0, 0); rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d", rec.Code)
}
})
t.Run("Feed failure keeps the last result", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
h.store.result = newStoredUpdateResult()
h.store.found = true
h.feed.err = update.ErrFeedUnavailable
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d", rec.Code)
}
if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) || h.store.saves != 0 {
t.Errorf("store changed: %+v", h.store.result)
}
if body := rec.Body.String(); body != "Internal server error\n" {
t.Errorf("body leaks detail: %q", body)
}
})
t.Run("Check is rate limited per user", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
loginLimited := middleware.NewRateLimiter(5, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
var codes []int
var last *httptest.ResponseRecorder
for i := 0; i < 6; i++ {
last = h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
codes = append(codes, last.Code)
}
for i, code := range codes[:5] {
if code != http.StatusOK {
t.Errorf("check %d status = %d", i+1, code)
}
}
if codes[5] != http.StatusTooManyRequests || last.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" {
t.Fatalf("6th status = %d body = %q", codes[5], last.Body.String())
}
if seconds, err := strconv.Atoi(last.Header().Get("Retry-After")); err != nil || seconds < 1 {
t.Errorf("Retry-After = %q", last.Header().Get("Retry-After"))
}
if h.feed.calls != 5 {
t.Errorf("feed calls = %d", h.feed.calls)
}
if other := h.sendAs(t, http.MethodPost, "/update/check", updateSecondOwner); other.Code != http.StatusOK {
t.Errorf("another owner status = %d", other.Code)
}
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
loginLimited.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/login", nil))
if rec.Code != http.StatusOK {
t.Errorf("auth limiter request %d status = %d", i+1, rec.Code)
}
}
})
t.Run("download and restart are not mounted", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
for _, path := range []string{"/update/download", "/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)
}
}
})
}
+241
View File
@@ -0,0 +1,241 @@
package test
import (
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"reflect"
"testing"
"time"
)
type fakeReleaseFeed struct {
release update.Release
err error
calls int
}
var _ update.ReleaseFeed = &fakeReleaseFeed{}
func (f *fakeReleaseFeed) Latest() (update.Release, error) {
f.calls++
if f.err != nil {
return update.Release{}, f.err
}
return f.release, nil
}
type fakeUpdateStore struct {
result update.CheckResult
found bool
saveErr error
saves int
}
var _ update.StatusStore = &fakeUpdateStore{}
func (f *fakeUpdateStore) Load() (update.CheckResult, bool) {
return f.result, f.found
}
func (f *fakeUpdateStore) Save(result update.CheckResult) error {
f.saves++
if f.saveErr != nil {
return f.saveErr
}
f.result = result
f.found = true
return nil
}
var updateTestCheckedAt = time.Date(2026, 9, 30, 9, 41, 0, 0, time.UTC)
func newStoredUpdateResult() update.CheckResult {
return update.CheckResult{
Latest: "v2.3.0-beta",
ReleaseURL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
Notes: update.ReleaseNotes{
Version: "v2.3.0-beta",
ReleasedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
Items: []string{"Media library"},
},
CheckedAt: updateTestCheckedAt,
}
}
func TestVersionComparison(t *testing.T) {
cases := []struct {
current, latest string
available bool
}{
{"v2.2.0-beta", "v2.3.0-beta", true},
{"v2.5.0-beta", "v2.5.0", true},
{"v2.5.0", "v2.5.0-beta", false},
{"v2.9.0", "v2.10.0", true},
{"v2.5.0-beta", "v2.5.0-beta", false},
{"v2.5.0", "v2.4.9", false},
{"dev", "v9.9.9", false},
{"v2.5.0-beta.2", "v2.5.0-beta.10", true},
{"v2.5.0-alpha", "v2.5.0-beta", true},
{"2.0.0-beta", "v2.1.0-beta", true},
{"v2.5", "v2.5.0", false},
{"v2.2.0", "garbage", false},
{"v1.0.0-1", "v1.0.0-alpha", true},
{"v1.0.0-alpha", "v1.0.0-1", false},
{"v1.0.0-beta", "v1.0.0-beta.1", true},
{"v1.0.0-beta.1", "v1.0.0-beta", false},
{"v1.0.0-beta.2", "v1.0.0-beta.1", false},
{"v1.0.0-beta", "v1.0.0-alpha", false},
{"v1.0.0", "v1.0.0", false},
{"v1.1.0", "v1.0.9", false},
}
for _, c := range cases {
t.Run(c.current+" to "+c.latest, func(t *testing.T) {
if got := update.IsNewer(c.current, c.latest); got != c.available {
t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.available)
}
})
}
}
func TestParseVersionRejectsGarbage(t *testing.T) {
for _, raw := range []string{"", "dev", "v", "v1.x.0", "v1.2.3-", "v-1.0.0"} {
if _, err := update.ParseVersion(raw); !errors.Is(err, update.ErrInvalidVersion) {
t.Errorf("ParseVersion(%q) error = %v, want ErrInvalidVersion", raw, err)
}
}
}
func TestNewestReleaseIsChosenRegardlessOfOrder(t *testing.T) {
releases := []update.Release{{Tag: "v2.4.0-beta"}, {Tag: "v2.5.0-beta"}, {Tag: "v2.3.0-beta"}}
newest, err := update.Newest(releases)
if err != nil || newest.Tag != "v2.5.0-beta" {
t.Fatalf("Newest = %q, %v, want v2.5.0-beta", newest.Tag, err)
}
}
func TestNewestReleaseOfNothingIsAnError(t *testing.T) {
if _, err := update.Newest(nil); !errors.Is(err, update.ErrNoRelease) {
t.Errorf("error = %v, want ErrNoRelease", err)
}
if _, err := update.Newest([]update.Release{{Tag: "nightly"}}); !errors.Is(err, update.ErrNoRelease) {
t.Errorf("error = %v, want ErrNoRelease for an unparsable tag", err)
}
}
func TestReleaseNotesBecomeAPlainTextList(t *testing.T) {
published := time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC)
body := "## What's new\n- **Media** library\n- Fixed [caching](http://x) bug\n\n* Faster start"
notes := update.BuildNotes("v2.3.0-beta", published, body)
want := []string{"Media library", "Fixed caching bug", "Faster start"}
if !reflect.DeepEqual(notes.Items, want) {
t.Errorf("items = %v, want %v", notes.Items, want)
}
if !notes.ReleasedAt.Equal(published) || notes.Version != "v2.3.0-beta" {
t.Errorf("notes = %+v", notes)
}
}
func TestReleaseNotesWithoutBulletsHaveNoItems(t *testing.T) {
notes := update.BuildNotes("v1.0.0", time.Time{}, "Just an intro.\n\n**Group**\n")
if notes.Items == nil || len(notes.Items) != 0 {
t.Errorf("items = %#v, want an empty non-nil list", notes.Items)
}
}
func TestReleaseNotesStripInlineMarkdown(t *testing.T) {
notes := update.BuildNotes("v1.0.0", time.Time{}, "- Run `task build` and *enjoy* __it__")
if want := []string{"Run task build and enjoy it"}; !reflect.DeepEqual(notes.Items, want) {
t.Errorf("items = %v, want %v", notes.Items, want)
}
}
func TestGetUpdateStatusUseCase(t *testing.T) {
t.Run("never checked", func(t *testing.T) {
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, "v2.2.0-beta").GetStatus()
if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available {
t.Errorf("status = %+v", status)
}
if status.ReleaseURL != nil || status.Notes != nil || status.CheckedAt != nil || status.Progress != nil || status.Error != nil || status.StartedAt != nil {
t.Errorf("never-checked values must be nil: %+v", status)
}
if status.State != update.StateIdle || status.CanApply {
t.Errorf("state = %q, can_apply = %v", status.State, status.CanApply)
}
})
t.Run("stored result", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
status := useCases.NewGetUpdateStatusUseCase(store, "v2.2.0-beta").GetStatus()
if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle {
t.Errorf("status = %+v", status)
}
if status.ReleaseURL == nil || *status.ReleaseURL != store.result.ReleaseURL {
t.Errorf("release_url = %v", status.ReleaseURL)
}
if status.CheckedAt == nil || !status.CheckedAt.Equal(updateTestCheckedAt) {
t.Errorf("checked_at = %v", status.CheckedAt)
}
})
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 {
t.Error("a dev build must not report an update")
}
})
}
func TestCheckForUpdateUseCase(t *testing.T) {
newRelease := update.Release{Tag: "v2.3.0-beta", URL: "https://git.example/r/v2.3.0-beta", PublishedAt: updateTestCheckedAt}
now := func() time.Time { return updateTestCheckedAt }
t.Run("stores the result and reports it", func(t *testing.T) {
store := &fakeUpdateStore{}
feed := &fakeReleaseFeed{release: newRelease}
status, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
if err != nil || !status.Available || status.Latest != "v2.3.0-beta" {
t.Fatalf("status = %+v, err = %v", status, err)
}
if store.result.Latest != "v2.3.0-beta" || store.result.ReleaseURL != newRelease.URL || !store.result.CheckedAt.Equal(updateTestCheckedAt) {
t.Errorf("stored = %+v", store.result)
}
})
t.Run("nothing newer", func(t *testing.T) {
feed := &fakeReleaseFeed{release: update.Release{Tag: "v2.2.0-beta"}}
status, err := useCases.NewCheckForUpdateUseCase(feed, &fakeUpdateStore{}, "v2.2.0-beta", now).Check()
if err != nil || status.Available {
t.Errorf("status = %+v, err = %v", status, err)
}
})
t.Run("feed failure keeps the previous result", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
feed := &fakeReleaseFeed{err: update.ErrFeedUnavailable}
_, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
if !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
if store.saves != 0 || store.result.Latest != "v2.3.0-beta" {
t.Errorf("store changed: %+v saves %d", store.result, store.saves)
}
})
t.Run("store failure is returned", func(t *testing.T) {
store := &fakeUpdateStore{saveErr: errRepositoryFailure}
feed := &fakeReleaseFeed{release: newRelease}
if _, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check(); !errors.Is(err, errRepositoryFailure) {
t.Errorf("err = %v", err)
}
})
}
+26 -3
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { useApi } from '@/composables/useApi'
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
import AppBoot from '@/components/AppBoot.vue'
@@ -9,6 +10,7 @@ import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
import AppLogo from '@/components/AppLogo.vue'
import ErrorView from '@/components/ErrorView.vue'
import OfflineBanner from '@/components/OfflineBanner.vue'
import UpdatePill from '@/components/UpdatePill.vue'
import UserMenu from '@/components/UserMenu.vue'
const props = withDefaults(
@@ -43,6 +45,7 @@ const bodyUi = computed(() => {
const route = useRoute()
const router = useRouter()
const session = useSessionStore()
const update = useUpdateStore()
const api = useApi()
const navItems = computed(() =>
@@ -52,7 +55,7 @@ const navItems = computed(() =>
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings' },
{ label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' },
].map((item) => ({
...item,
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
@@ -154,7 +157,11 @@ async function onLogout() {
:collapsed="sidebarCollapsed"
aria-label="Main"
:ui="navUi"
/>
>
<template #settings-trailing>
<UpdatePill v-if="update.showCue && !sidebarCollapsed" />
</template>
</UNavigationMenu>
<template #footer>
<UserMenu
@@ -172,7 +179,6 @@ async function onLogout() {
<UDashboardPanel :ui="{ body: bodyUi }">
<template #header>
<UDashboardNavbar
:toggle="{ ui: { leadingIcon: 'size-5' } }"
:title="pageTitle"
:ui="{
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
@@ -181,6 +187,23 @@ async function onLogout() {
toggle: 'size-11 justify-center',
}"
>
<template #toggle>
<span class="relative lg:hidden">
<UDashboardSidebarToggle
data-slot="toggle"
color="neutral"
variant="ghost"
class="size-11 justify-center"
:ui="{ leadingIcon: 'size-5' }"
/>
<span
v-if="update.showCue"
role="img"
aria-label="Update available"
class="absolute top-2.25 right-2.25 size-2.5 border-2 border-(--ui-bg) bg-info"
/>
</span>
</template>
<template #leading>
<UDashboardSidebarCollapse
color="neutral"
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
version: string
date: string
items: string[]
}>()
const open = ref(false)
</script>
<template>
<UCollapsible
v-model:open="open"
class="flex flex-col gap-2.5"
:ui="{ content: 'data-[state=closed]:hidden' }"
>
<UButton
color="neutral"
variant="link"
:icon="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
class="min-h-11 self-start gap-1.5 p-0 font-medium text-highlighted md:min-h-8"
:ui="{ leadingIcon: 'size-3.5' }"
>
What's new in {{ version }}
</UButton>
<template #content>
<div class="flex flex-col gap-2 pl-5">
<span class="text-xs text-muted">{{ date }}</span>
<ul class="flex flex-col gap-2">
<li v-for="item in items" :key="item" class="flex gap-2">
<span aria-hidden="true" class="mt-2 size-1 shrink-0 bg-muted" />
<span class="text-sm text-pretty text-toned">{{ item }}</span>
</li>
</ul>
</div>
</template>
</UCollapsible>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
const props = defineProps<{
line: string
}>()
const emit = defineEmits<{
copy: []
}>()
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? 'Copied' : 'Copy'))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
async function copyLine() {
try {
await copy(props.line)
} catch {
return
}
emit('copy')
}
</script>
<template>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-highlighted">For whoever manages the server</span>
<div class="flex flex-col gap-2 md:flex-row md:items-start">
<code
class="min-w-0 flex-1 truncate bg-elevated px-3 py-2 font-mono text-code/5.5 text-highlighted"
>{{ line }}</code
>
<UButton
color="neutral"
variant="outline"
:icon="buttonIcon"
aria-live="polite"
class="h-11 justify-center gap-1.5 border border-accented bg-default px-3 ring-0 md:h-8"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-4' }"
@click="copyLine"
>
{{ buttonLabel }}
</UButton>
</div>
<span class="text-xs/5 text-muted"
>They run this once on the server. After that, Update and Restart appear here.</span
>
</div>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
export type UpdateBannerState = 'available'
defineProps<{
state: UpdateBannerState
version: string
}>()
const emit = defineEmits<{
'go-to-settings': []
dismiss: []
}>()
</script>
<template>
<UAlert
role="region"
aria-label="Update available"
color="info"
variant="soft"
orientation="horizontal"
icon="i-lucide-circle-arrow-up"
:title="`Version ${version} is available`"
description="Update it from Settings. It takes about 2 minutes and the site stays online."
:ui="{
root: 'flex-wrap items-start gap-x-2.5 gap-y-3 border border-default py-3 ring-0 md:flex-nowrap md:items-center',
wrapper: 'basis-0 gap-0.5',
title: 'text-sm text-highlighted',
description: 'mt-0 text-sm/5 text-toned',
actions: 'max-md:w-full max-md:basis-full',
}"
>
<template #actions>
<div class="flex w-full items-center gap-2">
<UButton
color="neutral"
variant="outline"
class="h-11 flex-1 justify-center border border-accented bg-default px-3 text-highlighted ring-0 md:h-8 md:flex-none"
@click="emit('go-to-settings')"
>
Go to Settings
</UButton>
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-x"
aria-label="Dismiss"
class="size-11 shrink-0 justify-center text-muted md:size-8"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('dismiss')"
/>
</div>
</template>
</UAlert>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<UBadge label="Update" color="info" variant="solid" class="ml-auto h-4.5 px-1.5 py-0 text-xs" />
</template>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
export type UpdateTone = 'neutral' | 'success' | 'info' | 'warning' | 'error'
const TONE_CLASSES: Record<UpdateTone, { block: string; icon: string }> = {
neutral: { block: 'bg-muted', icon: 'text-toned' },
success: { block: 'bg-(--rc-success-soft)', icon: 'text-success' },
info: { block: 'bg-(--rc-info-soft)', icon: 'text-info' },
warning: { block: 'bg-(--rc-warning-soft)', icon: 'text-warning' },
error: { block: 'bg-(--rc-error-soft)', icon: 'text-error' },
}
const props = defineProps<{
tone: UpdateTone
icon: string
title: string
text: string
meta?: string
}>()
const toneClasses = computed(() => TONE_CLASSES[props.tone])
const spinning = computed(() => props.icon === 'i-lucide-loader-circle')
</script>
<template>
<div aria-live="polite" class="flex gap-3 p-3.5" :class="toneClasses.block">
<span class="flex pt-0.5">
<UIcon
:name="icon"
class="size-4.5 shrink-0"
:class="[
toneClasses.icon,
spinning ? 'animate-spin motion-reduce:animate-none' : '',
]"
/>
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-base font-semibold text-highlighted">{{ title }}</span>
<span class="text-sm text-pretty text-toned">{{ text }}</span>
<span v-if="meta" class="text-xs/5 text-muted">{{ meta }}</span>
</div>
</div>
</template>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed } from 'vue'
import ReleaseNotes from '@/components/ReleaseNotes.vue'
import ServerCommand from '@/components/ServerCommand.vue'
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
import type { UpdateStatus } from '@/composables/useUpdate'
import type { UserRole } from '@/composables/useApi'
import type { UpdateCheckPhase } from '@/stores/update'
import {
ENABLE_BROWSER_UPDATES_COMMAND,
displayVersion,
formatLastChecked,
formatReleased,
} from '@/lib/updateFormat'
interface Block {
tone: UpdateTone
icon: string
title: string
text: string
meta?: string
}
interface ActionButton {
label: string
icon: string
primary: boolean
disabled: boolean
}
const props = defineProps<{
status: UpdateStatus
role: UserRole | undefined
phase: UpdateCheckPhase
retryLabel: string
retryActive: boolean
}>()
const emit = defineEmits<{
check: []
}>()
const isOwner = computed(() => props.role === 'owner')
const newVersion = computed(() => displayVersion(props.status.latest))
const lastChecked = computed(() =>
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
)
const block = computed<Block>(() => {
if (isOwner.value && props.phase === 'checking') {
return {
tone: 'neutral',
icon: 'i-lucide-loader-circle',
title: 'Checking for updates…',
text: 'This usually takes a few seconds.',
}
}
if (isOwner.value && props.phase === 'limited') {
return {
tone: 'info',
icon: 'i-lucide-info',
title: 'Too many tries',
text: 'Wait a moment before trying again.',
}
}
if (isOwner.value && props.phase === 'failed') {
return {
tone: 'warning',
icon: 'i-lucide-wifi-off',
title: "Couldn't check for updates",
text: "RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
meta: lastChecked.value,
}
}
if (props.status.available && isOwner.value) {
return {
tone: 'info',
icon: 'i-lucide-info',
title: `Version ${newVersion.value} is available`,
text: "This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
}
}
if (props.status.available) {
return {
tone: 'info',
icon: 'i-lucide-circle-arrow-up',
title: `Version ${newVersion.value} is available`,
text: `You have ${props.status.current}. Only the owner can update RenewCMS. Ask the owner of this site.`,
}
}
return {
tone: 'success',
icon: 'i-lucide-circle-check',
title: "You're up to date",
text: isOwner.value
? 'You have the latest version of RenewCMS.'
: 'Only the owner can update or restart the site.',
meta: isOwner.value ? lastChecked.value : undefined,
}
})
const showServerGuidance = computed(
() => isOwner.value && props.status.available && props.phase === 'idle',
)
const actionButton = computed<ActionButton | null>(() => {
if (!isOwner.value) {
return null
}
if (props.phase === 'checking') {
return {
label: 'Checking…',
icon: 'i-lucide-loader-circle',
primary: false,
disabled: true,
}
}
if (props.phase === 'failed') {
return { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: true, disabled: false }
}
if (props.phase === 'limited') {
return props.retryActive
? {
label: `Try again in ${props.retryLabel}`,
icon: 'i-lucide-clock',
primary: false,
disabled: true,
}
: { label: 'Try again', icon: 'i-lucide-rotate-cw', primary: false, disabled: false }
}
if (props.status.available) {
return null
}
return {
label: 'Check for updates',
icon: 'i-lucide-refresh-cw',
primary: false,
disabled: false,
}
})
const notes = computed(() => {
const items = props.status.notes?.items ?? []
if (!showServerGuidance.value || items.length === 0 || !props.status.notes) {
return null
}
return {
items,
date: formatReleased(props.status.notes.released_at),
}
})
</script>
<template>
<section
aria-labelledby="updates-h"
data-testid="updates-card"
class="flex flex-col gap-4 border border-default bg-default p-4 lg:p-5"
>
<h2
id="updates-h"
tabindex="-1"
class="self-start text-lg font-semibold text-highlighted focus:outline-2 focus:outline-offset-4 focus:outline-primary"
>
Updates
</h2>
<UpdateStatusBlock
:tone="block.tone"
:icon="block.icon"
:title="block.title"
:text="block.text"
:meta="block.meta"
/>
<ReleaseNotes v-if="notes" :version="newVersion" :date="notes.date" :items="notes.items" />
<ServerCommand v-if="showServerGuidance" :line="ENABLE_BROWSER_UPDATES_COMMAND" />
<div v-if="actionButton" class="flex flex-col gap-2 md:flex-row md:items-center">
<UButton
:color="actionButton.primary ? 'primary' : 'neutral'"
:variant="actionButton.primary ? 'solid' : 'outline'"
:icon="actionButton.icon"
:disabled="actionButton.disabled"
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
:class="
actionButton.primary
? ''
: 'border border-accented bg-default text-highlighted ring-0'
"
:ui="{ leadingIcon: 'size-4' }"
@click="emit('check')"
>
{{ actionButton.label }}
</UButton>
</div>
<a
v-if="showServerGuidance && status.release_url"
:href="status.release_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 self-start text-sm font-medium text-highlighted underline underline-offset-3"
>
How to update
<UIcon name="i-lucide-arrow-up-right" class="size-3.5" />
</a>
</section>
</template>
+44
View File
@@ -0,0 +1,44 @@
import { useApi } from '@/composables/useApi'
export type UpdateState =
| 'idle'
| 'downloading'
| 'verifying'
| 'ready'
| 'restarting'
| 'succeeded'
| 'failed'
export interface UpdateNotes {
version: string
released_at: string
items: string[]
}
export interface UpdateStatus {
current: string
latest: string
available: boolean
release_url: string | null
notes: UpdateNotes | null
checked_at: string | null
can_apply: boolean
state: UpdateState
progress: unknown
error: string | null
started_at: string | null
}
export function useUpdate() {
const { request } = useApi()
function getUpdateStatus(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update')
}
function checkForUpdate(): Promise<UpdateStatus> {
return request<UpdateStatus>('/v1/system/update/check', { method: 'POST' })
}
return { getUpdateStatus, checkForUpdate }
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
AUTO_CHECK_INTERVAL_MS,
displayVersion,
formatLastChecked,
formatReleased,
isCheckDue,
} from '@/lib/updateFormat'
describe('displayVersion', () => {
it.each([
['v2.3.0-beta', '2.3.0-beta'],
['2.0.0-beta', '2.0.0-beta'],
['v2.5.0', '2.5.0'],
])('shows %s as %s', (tag, expected) => {
expect(displayVersion(tag)).toBe(expected)
})
})
describe('formatLastChecked', () => {
const now = new Date(2026, 8, 30, 12, 0, 0)
it('reads today at the local time', () => {
expect(formatLastChecked(new Date(2026, 8, 30, 9, 41).toISOString(), now)).toBe(
'Last checked today at 09:41',
)
})
it('reads yesterday at the local time', () => {
expect(formatLastChecked(new Date(2026, 8, 29, 18, 12).toISOString(), now)).toBe(
'Last checked yesterday at 18:12',
)
})
it('reads an older day as an absolute date', () => {
expect(formatLastChecked(new Date(2026, 8, 20, 7, 5).toISOString(), now)).toBe(
'Last checked 20 Sep 2026 at 07:05',
)
})
})
describe('isCheckDue', () => {
const now = Date.parse('2026-09-30T12:00:00Z')
it('is due when never checked', () => {
expect(isCheckDue(null, now)).toBe(true)
})
it('is due once the last check is older than 24 hours', () => {
expect(isCheckDue(new Date(now - AUTO_CHECK_INTERVAL_MS - 1000).toISOString(), now)).toBe(
true,
)
})
it('is not due when the last check is under 24 hours old', () => {
expect(isCheckDue(new Date(now - AUTO_CHECK_INTERVAL_MS + 1000).toISOString(), now)).toBe(
false,
)
})
})
describe('formatReleased', () => {
it('reads the release date as an absolute date', () => {
expect(formatReleased('2026-09-24T08:00:00Z')).toBe('Released 24 Sep 2026')
})
})
+46
View File
@@ -0,0 +1,46 @@
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
export const ENABLE_BROWSER_UPDATES_COMMAND = 'renewcms enable-browser-updates'
export const AUTO_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000
export function displayVersion(tag: string) {
return tag.replace(/^v/, '')
}
function isSameLocalDay(a: Date, b: Date) {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
)
}
function formatClock(date: Date) {
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
export function formatLastChecked(iso: string, now: Date = new Date()) {
const date = new Date(iso)
const yesterday = new Date(now)
yesterday.setDate(now.getDate() - 1)
if (isSameLocalDay(date, now)) {
return `Last checked today at ${formatClock(date)}`
}
if (isSameLocalDay(date, yesterday)) {
return `Last checked yesterday at ${formatClock(date)}`
}
return `Last checked ${formatAbsoluteDateFromIso(iso)} at ${formatClock(date)}`
}
export function isCheckDue(checkedAt: string | null, now: number = Date.now()) {
if (checkedAt === null) {
return true
}
return now - new Date(checkedAt).getTime() > AUTO_CHECK_INTERVAL_MS
}
export function formatReleased(iso: string) {
return `Released ${formatAbsoluteDateFromIso(iso)}`
}
+138
View File
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
import { CHECK_PATH, STATUS_PATH, newerAvailable, upToDate } from '@/views/__tests__/updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let requestMock: ReturnType<typeof vi.fn>
function setup(role: 'owner' | 'admin', handlers: Record<string, () => Promise<unknown>>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, displayName: '', role }
session.status = 'ready'
requestMock = vi.fn((path: string) => handlers[path]!())
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
return useUpdateStore()
}
beforeEach(() => {
localStorage.clear()
})
describe('load', () => {
it('shares one request between concurrent callers', async () => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()) })
await Promise.all([store.load(), store.load()])
expect(requestMock).toHaveBeenCalledTimes(1)
expect(store.loadState).toBe('ready')
expect(store.status?.latest).toBe('v2.2.0-beta')
})
it('reports an error when nothing was ever loaded, and keeps the last status otherwise', async () => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.reject(new ApiError(500)) })
await store.load()
expect(store.loadState).toBe('error')
const kept = setup('owner', { [STATUS_PATH]: () => Promise.resolve(upToDate()) })
await kept.load()
requestMock.mockImplementation(() => Promise.reject(new NetworkError()))
await kept.load()
expect(kept.loadState).toBe('ready')
expect(kept.status).not.toBeNull()
})
})
describe('check', () => {
it('stores the result and returns to idle', async () => {
const store = setup('owner', { [CHECK_PATH]: () => Promise.resolve(newerAvailable()) })
const result = await store.check()
expect(result).toBe('updated')
expect(store.phase).toBe('idle')
expect(store.available).toBe(true)
expect(requestMock).toHaveBeenCalledWith(CHECK_PATH, { method: 'POST' })
})
it('answers busy and sends nothing while a check is running', async () => {
const store = setup('owner', { [CHECK_PATH]: () => new Promise(() => undefined) })
store.check()
expect(await store.check()).toBe('busy')
expect(requestMock).toHaveBeenCalledTimes(1)
})
it.each([
{
name: '429 with Retry-After',
error: new ApiError(429, 42),
result: 'limited',
phase: 'limited',
retry: 42,
},
{
name: '429 without Retry-After',
error: new ApiError(429),
result: 'limited',
phase: 'limited',
retry: 60,
},
{ name: '403', error: new ApiError(403), result: 'forbidden', phase: 'idle', retry: 0 },
{ name: '500', error: new ApiError(500), result: 'failed', phase: 'failed', retry: 0 },
{
name: 'a network failure',
error: new NetworkError(),
result: 'failed',
phase: 'failed',
retry: 0,
},
])('maps $name', async ({ error, result, phase, retry }) => {
const store = setup('owner', { [CHECK_PATH]: () => Promise.reject(error) })
expect(await store.check()).toBe(result)
expect(store.phase).toBe(phase)
expect(store.retryAfter).toBe(retry)
})
})
describe('banner dismissal', () => {
it('hides the banner for the dismissed version only and keeps the cue', async () => {
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
await store.load()
expect(store.bannerVisible).toBe(true)
store.dismissBanner()
expect(store.bannerVisible).toBe(false)
expect(store.showCue).toBe(true)
expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBe('v2.3.0-beta')
})
it('reads the remembered version when created', async () => {
localStorage.setItem(DISMISSED_VERSION_KEY, 'v2.3.0-beta')
const store = setup('owner', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
await store.load()
expect(store.bannerVisible).toBe(false)
})
it('shows no cue for an admin', async () => {
const store = setup('admin', { [STATUS_PATH]: () => Promise.resolve(newerAvailable()) })
await store.load()
expect(store.showCue).toBe(false)
expect(store.bannerVisible).toBe(false)
})
})
+10 -1
View File
@@ -51,6 +51,15 @@ export const useSessionStore = defineStore('session', () => {
return check()
}
async function refresh() {
const { getMe } = useApi()
try {
session.value = await getMe()
} catch {
return
}
}
function clear() {
session.value = null
status.value = 'ready'
@@ -62,5 +71,5 @@ export const useSessionStore = defineStore('session', () => {
}
}
return { status, session, check, retry, clear, setDisplayName }
return { status, session, check, retry, refresh, clear, setDisplayName }
})
+113
View File
@@ -0,0 +1,113 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ApiError } from '@/composables/useApi'
import { useUpdate, type UpdateStatus } from '@/composables/useUpdate'
import { useSessionStore } from '@/stores/session'
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
export type UpdateCheckResult = 'updated' | 'failed' | 'limited' | 'forbidden' | 'busy'
export const DISMISSED_VERSION_KEY = 'renewcms:update-dismissed'
const DEFAULT_RETRY_SECONDS = 60
function readDismissedVersion() {
try {
return localStorage.getItem(DISMISSED_VERSION_KEY)
} catch {
return null
}
}
function writeDismissedVersion(version: string) {
try {
localStorage.setItem(DISMISSED_VERSION_KEY, version)
} catch {
return
}
}
export const useUpdateStore = defineStore('update', () => {
const session = useSessionStore()
const status = ref<UpdateStatus | null>(null)
const loadState = ref<UpdateLoadState>('idle')
const phase = ref<UpdateCheckPhase>('idle')
const retryAfter = ref(0)
const dismissedVersion = ref<string | null>(readDismissedVersion())
let pendingLoad: Promise<void> | null = null
const isOwner = computed(() => session.session?.role === 'owner')
const available = computed(() => status.value?.available === true)
const showCue = computed(() => isOwner.value && available.value)
const bannerVisible = computed(
() => showCue.value && status.value?.latest !== dismissedVersion.value,
)
function load() {
if (pendingLoad) {
return pendingLoad
}
const { getUpdateStatus } = useUpdate()
loadState.value = 'loading'
pendingLoad = Promise.resolve()
.then(getUpdateStatus)
.then((result) => {
status.value = result
loadState.value = 'ready'
})
.catch(() => {
loadState.value = status.value === null ? 'error' : 'ready'
})
.finally(() => {
pendingLoad = null
})
return pendingLoad
}
async function check(): Promise<UpdateCheckResult> {
if (phase.value === 'checking') {
return 'busy'
}
const { checkForUpdate } = useUpdate()
phase.value = 'checking'
try {
status.value = await checkForUpdate()
loadState.value = 'ready'
phase.value = 'idle'
return 'updated'
} catch (error) {
if (error instanceof ApiError && error.status === 429) {
retryAfter.value = error.retryAfter ?? DEFAULT_RETRY_SECONDS
phase.value = 'limited'
return 'limited'
}
if (error instanceof ApiError && error.status === 403) {
phase.value = 'idle'
return 'forbidden'
}
phase.value = 'failed'
return 'failed'
}
}
function dismissBanner() {
if (status.value) {
dismissedVersion.value = status.value.latest
writeDismissedVersion(status.value.latest)
}
}
return {
status,
loadState,
phase,
retryAfter,
isOwner,
available,
showCue,
bannerVisible,
load,
check,
dismissBanner,
}
})
+23 -1
View File
@@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
import NewArticleModal from '@/components/NewArticleModal.vue'
import UpdateBanner from '@/components/UpdateBanner.vue'
import { useArticles, type Article } from '@/composables/useArticles'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
import { accountName } from '@/lib/accountName'
import { formatRelativeDate } from '@/lib/relativeDate'
import { displayVersion } from '@/lib/updateFormat'
const SKELETON_DELAY_MS = 150
const RECENT_LIST_SIZE = 5
@@ -18,6 +21,7 @@ type LoadState = 'loading' | 'default' | 'empty' | 'error'
const router = useRouter()
const toast = useToast()
const session = useSessionStore()
const update = useUpdateStore()
const { listArticles } = useArticles()
const loadState = ref<LoadState>('loading')
@@ -111,6 +115,16 @@ function onArticleCreated(article: Article) {
})
}
watch(
() => update.isOwner,
(isOwner) => {
if (isOwner) {
update.load()
}
},
{ immediate: true },
)
onMounted(loadArticles)
</script>
@@ -145,6 +159,14 @@ onMounted(loadArticles)
</UButton>
</div>
<UpdateBanner
v-if="update.bannerVisible && update.status"
state="available"
:version="displayVersion(update.status.latest)"
@go-to-settings="router.push('/settings')"
@dismiss="update.dismissBanner"
/>
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
<div class="grid grid-cols-3 gap-3">
<div
+99 -3
View File
@@ -1,8 +1,15 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useToast } from '@nuxt/ui/composables'
import DashboardShell from '@/components/DashboardShell.vue'
import UpdatesCard from '@/components/UpdatesCard.vue'
import { ApiError } from '@/composables/useApi'
import { useRetryAfter } from '@/composables/useRetryAfter'
import { useSettings } from '@/composables/useSettings'
import type { UpdateStatus } from '@/composables/useUpdate'
import { isCheckDue } from '@/lib/updateFormat'
import { useSessionStore } from '@/stores/session'
import { useUpdateStore } from '@/stores/update'
const SKELETON_DELAY_MS = 150
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
@@ -10,6 +17,10 @@ const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try
type LoadState = 'loading' | 'ready' | 'error'
const { getSettings } = useSettings()
const toast = useToast()
const session = useSessionStore()
const update = useUpdateStore()
const retry = useRetryAfter()
const loadState = ref<LoadState>('loading')
const version = ref('')
@@ -49,13 +60,88 @@ async function loadSettings() {
}
}
onMounted(loadSettings)
function neverCheckedStatus(): UpdateStatus {
return {
current: version.value,
latest: '',
available: false,
release_url: null,
notes: null,
checked_at: null,
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
}
}
const updatesStatus = computed(() => {
if (update.status) {
return update.status
}
return update.loadState === 'error' && update.isOwner ? neverCheckedStatus() : null
})
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
async function onCheck() {
const result = await update.check()
if (result !== 'forbidden') {
return
}
toast.add({
color: 'error',
icon: 'i-lucide-circle-x',
title: 'Only the owner can do this',
description: 'Ask the owner of this site to update RenewCMS.',
})
await session.refresh()
await update.load()
}
const updateRequested = ref(false)
let autoCheckHandled = false
watch(
() => [session.status, updateRequested.value, update.loadState] as const,
([sessionStatus, requested, loadStateOfUpdate]) => {
if (
autoCheckHandled ||
!requested ||
sessionStatus !== 'ready' ||
loadStateOfUpdate !== 'ready'
) {
return
}
autoCheckHandled = true
if (update.isOwner && isCheckDue(update.status?.checked_at ?? null)) {
onCheck()
}
},
{ immediate: true },
)
watch(
() => update.phase,
(phase) => {
if (phase === 'limited') {
retry.start(update.retryAfter)
}
},
)
onMounted(async () => {
loadSettings()
await update.load()
updateRequested.value = true
})
onBeforeUnmount(clearSkeletonTimer)
</script>
<template>
<DashboardShell>
<div class="flex max-w-180 flex-col gap-5">
<div class="flex max-w-164 flex-col gap-5">
<div class="flex flex-col gap-0.5">
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
<p class="text-muted">Workspace information.</p>
@@ -106,6 +192,16 @@ onBeforeUnmount(clearSkeletonTimer)
>
</div>
</div>
<UpdatesCard
v-if="updatesStatus"
:status="updatesStatus"
:role="session.session?.role"
:phase="updatesPhase"
:retry-label="retry.formatted.value"
:retry-active="retry.active.value"
@check="onCheck"
/>
</div>
</DashboardShell>
</template>
@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import type { UserRole } from '@/composables/useApi'
import type { UpdateStatus } from '@/composables/useUpdate'
import { DISMISSED_VERSION_KEY, useUpdateStore } from '@/stores/update'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import HomeView from '../HomeView.vue'
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
function mockResponse(status: number, body: unknown = null) {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: () => null },
json: async () => body,
} as unknown as Response
}
let wrapper: VueWrapper | null = null
interface MountOptions {
role: UserRole
status: UpdateStatus
width?: number
}
async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions) {
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', role }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === '/v1/article/') {
return Promise.resolve(mockResponse(200, []))
}
if (url === '/v1/system/update') {
return Promise.resolve(mockResponse(200, status))
}
throw new Error(`Unexpected fetch call: ${url}`)
}) as unknown as typeof fetch
await router.push('/home')
wrapper = mount(HomeView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function banner() {
return document.body.querySelector('[aria-label="Update available"][role="region"]')
}
function settingsNavItem() {
return document.body.querySelector('nav[aria-label="Main"] a[href="/settings"]')!
}
function mobileDot() {
return document.body.querySelector('[role="img"][aria-label="Update available"]')
}
function buttonByLabel(label: string) {
return Array.from(document.body.querySelectorAll('button')).find(
(button) =>
button.textContent?.trim() === label || button.getAttribute('aria-label') === label,
)
}
beforeEach(() => {
localStorage.clear()
})
afterEach(async () => {
wrapper?.unmount()
wrapper = null
await router.push('/login')
document.body.innerHTML = ''
})
describe('Home banner for the owner, update available', () => {
it('shows the info region above the content with copy and both buttons', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
const region = banner()!
expect(region).not.toBeNull()
expect(region.textContent).toContain('Version 2.3.0-beta is available')
expect(region.textContent).toContain(
'Update it from Settings. It takes about 2 minutes and the site stays online.',
)
expect(region.textContent).toContain('Go to Settings')
expect(region.querySelector('button[aria-label="Dismiss"]')).not.toBeNull()
const stats = document.body.querySelector('[data-testid="greeting"]')!
expect(
stats.compareDocumentPosition(region) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy()
})
it('goes to /settings from Go to Settings', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
buttonByLabel('Go to Settings')!.click()
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/settings'))
})
it('hides on dismiss, remembers the version, and stays hidden after a remount', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
buttonByLabel('Dismiss')!.click()
await flushPromises()
expect(banner()).toBeNull()
expect(localStorage.getItem(DISMISSED_VERSION_KEY)).toBe(NEW_TAG)
wrapper?.unmount()
document.body.innerHTML = ''
await mountHome({ role: 'owner', status: newerAvailable() })
expect(banner()).toBeNull()
})
it('shows again when latest changes to a newer version', async () => {
localStorage.setItem(DISMISSED_VERSION_KEY, NEW_TAG)
await mountHome({ role: 'owner', status: newerAvailable({ latest: 'v2.4.0-beta' }) })
expect(banner()).not.toBeNull()
expect(banner()!.textContent).toContain('Version 2.4.0-beta is available')
})
it('is absent for an admin and when nothing is newer', async () => {
await mountHome({ role: 'admin', status: newerAvailable() })
expect(banner()).toBeNull()
wrapper?.unmount()
document.body.innerHTML = ''
await mountHome({ role: 'owner', status: upToDate() })
expect(banner()).toBeNull()
})
})
describe('Settings sidebar pill and mobile dot', () => {
it('shows the Update pill and the dot for the owner when available', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
expect(settingsNavItem().textContent).toContain('Update')
expect(mobileDot()).not.toBeNull()
})
it('keeps the pill and the dot after the banner is dismissed', async () => {
await mountHome({ role: 'owner', status: newerAvailable() })
buttonByLabel('Dismiss')!.click()
await flushPromises()
expect(banner()).toBeNull()
expect(settingsNavItem().textContent).toContain('Update')
expect(mobileDot()).not.toBeNull()
})
it('shows the dot on the mobile menu icon', async () => {
await mountHome({ role: 'owner', status: newerAvailable(), width: MOBILE_WIDTH })
expect(mobileDot()).not.toBeNull()
expect(banner()).not.toBeNull()
})
it.each([
{ name: 'an admin', role: 'admin' as const, status: newerAvailable() },
{ name: 'an owner who is up to date', role: 'owner' as const, status: upToDate() },
])('shows no pill and no dot for $name', async ({ role, status }) => {
await mountHome({ role, status })
expect(settingsNavItem().textContent).not.toContain('Update')
expect(mobileDot()).toBeNull()
expect(banner()).toBeNull()
})
})
describe('Update store cue rules', () => {
it('does not request the status for a non-owner on any page', async () => {
await mountHome({ role: 'admin', status: newerAvailable() })
const requested = vi
.mocked(global.fetch)
.mock.calls.some(([url]) => url === '/v1/system/update')
expect(requested).toBe(false)
expect(useUpdateStore().status).toBeNull()
})
})
@@ -0,0 +1,459 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { ApiError, NetworkError, useApi, type UserRole } from '@/composables/useApi'
import type { UpdateStatus } from '@/composables/useUpdate'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import {
CHECK_PATH,
NEW_TAG,
NOTE_ITEMS,
RELEASE_URL,
RUNNING_VERSION,
STATUS_PATH,
findButton,
hoursAgo,
newerAvailable,
stubClipboard,
upToDate,
visibleButtonLabels,
} from './updateTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return {
...actual,
useApi: vi.fn(),
}
})
let toastAdd: ReturnType<typeof vi.fn>
vi.mock('@nuxt/ui/composables', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nuxt/ui/composables')>()
return {
...actual,
useToast: () => ({ add: toastAdd }),
}
})
type Handler = () => Promise<unknown>
let requestMock: ReturnType<typeof vi.fn>
let getMeMock: ReturnType<typeof vi.fn>
let wrapper: VueWrapper | null = null
interface MountOptions {
role: UserRole
status?: Handler | UpdateStatus
check?: Handler | UpdateStatus
meAfterRefresh?: UserRole
}
function asHandler(value: Handler | UpdateStatus | undefined, fallback: Handler): Handler {
if (value === undefined) {
return fallback
}
return typeof value === 'function' ? value : () => Promise.resolve(value)
}
async function mountSettings(options: MountOptions) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role: options.role }
session.status = 'ready'
const statusHandler = asHandler(options.status, () => Promise.resolve(upToDate()))
const checkHandler = asHandler(options.check, () => Promise.resolve(upToDate()))
requestMock = vi.fn((path: string) => {
if (path === '/v1/settings/') {
return Promise.resolve({ version: RUNNING_VERSION })
}
if (path === STATUS_PATH) {
return statusHandler()
}
if (path === CHECK_PATH) {
return checkHandler()
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({
verified: true,
username: 'marta',
id: 1,
displayName: '',
role: options.meAfterRefresh ?? options.role,
})
vi.mocked(useApi).mockReturnValue({ getMe: getMeMock, request: requestMock })
await router.push('/settings')
wrapper = mount(SettingsView, {
attachTo: document.body,
global: { plugins: [router, uiPlugin] },
})
await flushPromises()
return wrapper
}
function remount() {
wrapper?.unmount()
wrapper = null
document.body.innerHTML = ''
}
function updatesCard() {
return document.body.querySelector('[data-testid="updates-card"]')!
}
function cardText() {
return updatesCard().textContent ?? ''
}
function cardButtons() {
return visibleButtonLabels(updatesCard())
}
function callsTo(path: string) {
return requestMock.mock.calls.filter(([called]) => called === path)
}
function pendingForever(): Handler {
return () => new Promise(() => undefined)
}
afterEach(async () => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
await router.push('/login')
document.body.innerHTML = ''
})
describe('Settings, owner, Up to date', () => {
it("shows the success block, the last checked line and only 'Check for updates'", async () => {
await mountSettings({ role: 'owner', status: upToDate() })
const card = updatesCard()
expect(card.getAttribute('aria-labelledby')).toBe('updates-h')
expect(card.querySelector('#updates-h')?.textContent?.trim()).toBe('Updates')
expect(cardText()).toContain("You're up to date")
expect(cardText()).toContain('You have the latest version of RenewCMS.')
expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/)
expect(cardButtons()).toEqual(['Check for updates'])
expect(card.querySelector('[aria-live="polite"]')).not.toBeNull()
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('keeps the running version exactly as reported in the Version card', async () => {
await mountSettings({ role: 'owner', status: upToDate() })
expect(
document.body.querySelector('[data-testid="settings-version"]')?.textContent?.trim(),
).toBe('v2.2.0-beta')
})
})
describe('Settings, owner, Checking', () => {
it('shows the polite block, a disabled Checking button and sends one POST', async () => {
await mountSettings({ role: 'owner', status: upToDate(), check: pendingForever() })
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain('Checking for updates…')
expect(cardText()).toContain('This usually takes a few seconds.')
expect(updatesCard().querySelector('[aria-live="polite"]')?.textContent).toContain(
'Checking for updates…',
)
const checking = findButton('Checking…') as HTMLButtonElement
expect(checking.disabled).toBe(true)
expect(cardButtons()).toEqual(['Checking…'])
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
it('shows the result once the check resolves', async () => {
await mountSettings({ role: 'owner', status: upToDate(), check: newerAvailable() })
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain(`Version ${NEW_TAG.slice(1)} is available`)
})
})
describe('Settings, owner, Check failed', () => {
it.each([
{ name: 'a 500', failure: () => Promise.reject(new ApiError(500)) },
{ name: 'a network failure', failure: () => Promise.reject(new NetworkError()) },
])('keeps the last result and offers only Try again after $name', async ({ failure }) => {
await mountSettings({ role: 'owner', status: upToDate(), check: failure })
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain("Couldn't check for updates")
expect(cardText()).toContain(
"RenewCMS couldn't reach the update server. Check your connection and try again. Your site isn't affected.",
)
expect(cardText()).toMatch(/Last checked (today|yesterday) at \d\d:\d\d/)
expect(cardButtons()).toEqual(['Try again'])
})
it('runs the check again from Try again', async () => {
let attempt = 0
await mountSettings({
role: 'owner',
status: upToDate(),
check: () =>
attempt++ === 0 ? Promise.reject(new ApiError(500)) : Promise.resolve(upToDate()),
})
findButton('Check for updates')!.click()
await flushPromises()
findButton('Try again')!.click()
await flushPromises()
expect(callsTo(CHECK_PATH)).toHaveLength(2)
expect(cardText()).toContain("You're up to date")
})
it('treats a failed status load as a failed check for the owner, never for an admin', async () => {
await mountSettings({
role: 'owner',
status: () => Promise.reject(new ApiError(500)),
check: pendingForever(),
})
expect(cardText()).toContain("Couldn't check for updates")
expect(cardButtons()).toEqual(['Try again'])
remount()
await mountSettings({ role: 'admin', status: () => Promise.reject(new ApiError(500)) })
expect(document.body.querySelector('[data-testid="updates-card"]')).toBeNull()
})
})
describe('Settings, owner, newer version, not prepared', () => {
it('shows the notice, the server command, the note and the link, with no other button', async () => {
await mountSettings({
role: 'owner',
status: newerAvailable({
notes: { version: NEW_TAG, released_at: '2026-09-24T08:00:00Z', items: [] },
}),
})
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
"This site can't update itself from the browser yet. Whoever manages your server can set it up once, or update RenewCMS for you.",
)
expect(cardText()).toContain('For whoever manages the server')
expect(updatesCard().querySelector('code')?.textContent).toBe(
'renewcms enable-browser-updates',
)
expect(cardText()).toContain(
'They run this once on the server. After that, Update and Restart appear here.',
)
const link = Array.from(updatesCard().querySelectorAll('a')).find(
(anchor) => anchor.textContent?.trim() === 'How to update',
)!
expect(link.getAttribute('href')).toBe(RELEASE_URL)
expect(link.getAttribute('target')).toBe('_blank')
expect(link.getAttribute('rel')).toContain('noopener')
expect(cardButtons()).toEqual(['Copy'])
})
it('shows the version without its leading v while the Version card keeps the running one', async () => {
await mountSettings({ role: 'owner', status: newerAvailable() })
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).not.toContain('Version v2.3.0-beta')
expect(
document.body.querySelector('[data-testid="settings-version"]')?.textContent?.trim(),
).toBe('v2.2.0-beta')
})
})
describe('Settings, owner, newer version, notes open', () => {
it('is collapsed by default and toggles with aria-expanded, date and plain-text list', async () => {
await mountSettings({ role: 'owner', status: newerAvailable() })
const toggle = findButton("What's new in 2.3.0-beta")!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(cardText()).not.toContain('Released 24 Sep 2026')
toggle.click()
await flushPromises()
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(cardText()).toContain('Released 24 Sep 2026')
for (const item of NOTE_ITEMS) {
expect(cardText()).toContain(item)
}
expect(updatesCard().querySelectorAll('li')).toHaveLength(NOTE_ITEMS.length)
toggle.click()
await flushPromises()
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(cardText()).not.toContain('Released 24 Sep 2026')
})
})
describe('Settings, command copied', () => {
it('writes the command, reads Copied for 2 s, then Copy again', async () => {
const writeText = stubClipboard()
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
await mountSettings({ role: 'owner', status: newerAvailable() })
findButton('Copy')!.click()
await flushPromises()
expect(writeText).toHaveBeenCalledWith('renewcms enable-browser-updates')
expect(findButton('Copied')).toBeTruthy()
expect(findButton('Copied')!.getAttribute('aria-live')).toBe('polite')
await vi.advanceTimersByTimeAsync(1999)
expect(findButton('Copied')).toBeTruthy()
await vi.advanceTimersByTimeAsync(1)
expect(findButton('Copy')).toBeTruthy()
expect(findButton('Copied')).toBeUndefined()
})
})
describe('Settings, admin view', () => {
it('shows the newer version and the owner-only note, with no button', async () => {
await mountSettings({ role: 'admin', status: newerAvailable() })
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
)
expect(cardButtons()).toEqual([])
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('shows up to date with the owner-only note and no button, and never checks', async () => {
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
expect(cardText()).toContain("You're up to date")
expect(cardText()).toContain('Only the owner can update or restart the site.')
expect(cardButtons()).toEqual([])
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
})
describe('Settings, Role changed (403)', () => {
it('toasts, reloads the status and renders the admin view', async () => {
let reads = 0
await mountSettings({
role: 'owner',
meAfterRefresh: 'admin',
status: () => {
reads++
return Promise.resolve(reads === 1 ? upToDate() : newerAvailable())
},
check: () => Promise.reject(new ApiError(403)),
})
findButton('Check for updates')!.click()
await flushPromises()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
color: 'error',
title: 'Only the owner can do this',
description: 'Ask the owner of this site to update RenewCMS.',
}),
)
expect(getMeMock).toHaveBeenCalled()
expect(callsTo(STATUS_PATH)).toHaveLength(2)
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardButtons()).toEqual([])
})
})
describe('Settings, Too many requests (429)', () => {
it('shows the notice and a Try again button counting down to enabled', async () => {
vi.useFakeTimers({
toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'],
})
await mountSettings({
role: 'owner',
status: upToDate(),
check: () => Promise.reject(new ApiError(429, 42)),
})
findButton('Check for updates')!.click()
await flushPromises()
expect(cardText()).toContain('Too many tries')
expect(cardText()).toContain('Wait a moment before trying again.')
const waiting = findButton('Try again in 0:42') as HTMLButtonElement
expect(waiting.disabled).toBe(true)
await vi.advanceTimersByTimeAsync(1000)
expect(findButton('Try again in 0:41')).toBeTruthy()
await vi.advanceTimersByTimeAsync(41000)
const ready = findButton('Try again') as HTMLButtonElement
expect(ready.disabled).toBe(false)
})
})
describe('A check runs on the first open after 24 hours, for the owner only', () => {
it('checks once when checked_at is null', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: null }) })
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
it('checks once when checked_at is older than 24 hours', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(25) }) })
expect(callsTo(CHECK_PATH)).toHaveLength(1)
})
it('shows Checking while the automatic check runs', async () => {
await mountSettings({
role: 'owner',
status: upToDate({ checked_at: null }),
check: pendingForever(),
})
expect(cardText()).toContain('Checking for updates…')
})
it('sends no check when checked_at is under 24 hours old', async () => {
await mountSettings({ role: 'owner', status: upToDate({ checked_at: hoursAgo(23) }) })
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
it('never sends one for an admin', async () => {
await mountSettings({ role: 'admin', status: upToDate({ checked_at: null }) })
expect(callsTo(CHECK_PATH)).toHaveLength(0)
remount()
await mountSettings({ role: 'admin', status: upToDate({ checked_at: hoursAgo(48) }) })
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
})
describe('No Update, Restart or progress element exists in this slice', () => {
it.each([
{ name: 'up to date', role: 'owner' as const, status: upToDate() },
{ name: 'not prepared', role: 'owner' as const, status: newerAvailable() },
{ name: 'admin available', role: 'admin' as const, status: newerAvailable() },
{ name: 'admin up to date', role: 'admin' as const, status: upToDate() },
])('renders none for $name', async ({ role, status }) => {
await mountSettings({ role, status })
const labels = cardButtons()
expect(labels).not.toContain('Update')
expect(labels).not.toContain('Updating…')
expect(labels).not.toContain('Restart')
expect(updatesCard().querySelector('[role="progressbar"]')).toBeNull()
})
})
+66
View File
@@ -0,0 +1,66 @@
import { vi } from 'vitest'
import type { UpdateStatus } from '@/composables/useUpdate'
export const RUNNING_VERSION = 'v2.2.0-beta'
export const NEW_TAG = 'v2.3.0-beta'
export const RELEASE_URL = 'https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta'
export const RELEASED_AT = '2026-09-24T08:00:00Z'
export const NOTE_ITEMS = [
'Images upload faster, and large images no longer stall.',
'Fixed a problem where a draft could show an old title after saving.',
'Small improvements to the Users page.',
]
export const STATUS_PATH = '/v1/system/update'
export const CHECK_PATH = '/v1/system/update/check'
export function hoursAgo(hours: number) {
return new Date(Date.now() - hours * 60 * 60 * 1000).toISOString()
}
export function upToDate(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return {
current: RUNNING_VERSION,
latest: RUNNING_VERSION,
available: false,
release_url: RELEASE_URL,
notes: { version: RUNNING_VERSION, released_at: RELEASED_AT, items: [] },
checked_at: hoursAgo(1),
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
...overrides,
}
}
export function newerAvailable(overrides: Partial<UpdateStatus> = {}): UpdateStatus {
return upToDate({
latest: NEW_TAG,
available: true,
notes: { version: NEW_TAG, released_at: RELEASED_AT, items: NOTE_ITEMS },
...overrides,
})
}
export function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
return writeText
}
export function visibleButtonLabels(root: ParentNode = document.body) {
return Array.from(root.querySelectorAll('button'))
.map((button) => button.textContent?.trim() ?? '')
.filter((label) => label !== '')
}
export function findButton(label: string, root: ParentNode = document.body) {
return Array.from(root.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === label,
)
}
+134 -4
View File
@@ -164,6 +164,65 @@ const verifiedSession: ApiMock = {
},
}
const updateRunningVersion = 'v2.2.0-beta'
const updateLatestTag = 'v2.3.0'
const updateReleaseUrl = 'https://git.floriansylvain.fr/fsylvain/RenewCMS/releases/tag/v2.3.0'
const updateReleasedAt = '2026-09-24T08:00:00Z'
const adminSession: ApiMock = {
status: 200,
body: { ...(verifiedSession.body as object), role: 'admin' },
}
function updateStatusBody(overrides: Record<string, unknown> = {}) {
return {
current: updateRunningVersion,
latest: updateRunningVersion,
available: false,
release_url: updateReleaseUrl,
notes: { version: updateRunningVersion, released_at: updateReleasedAt, items: [] },
checked_at: new Date().toISOString(),
can_apply: false,
state: 'idle',
progress: null,
error: null,
started_at: null,
...overrides,
}
}
function updateNeverChecked() {
return updateStatusBody({
latest: '',
release_url: null,
notes: null,
checked_at: null,
})
}
function updateAvailable() {
return updateStatusBody({
latest: updateLatestTag,
available: true,
notes: { version: updateLatestTag, released_at: updateReleasedAt, items: [] },
})
}
const updateCheckHangs: ApiMock = { status: 200, hang: true }
function updateApi(
role: 'owner' | 'admin',
status: unknown,
check: ApiMock = { status: 200, body: updateStatusBody() },
): Record<string, ApiMock> {
return {
'GET /v1/auth/me': role === 'owner' ? verifiedSession : adminSession,
'GET /v1/settings/': { status: 200, body: { version: updateRunningVersion } },
'GET /v1/system/update': { status: 200, body: status },
'POST /v1/system/update/check': check,
}
}
const editorNow = '2026-09-26T11:02:00Z'
const bathroomBody =
@@ -1981,13 +2040,71 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
},
settings: {
Default: {
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
Checking: {
path: '/settings',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/settings/': { status: 200, body: { version: 'v2.2.0-beta' } },
api: updateApi('owner', updateNeverChecked(), updateCheckHangs),
},
'Check failed': { path: '/settings', skip: 'clock-sensitive' },
'Update available': { path: '/settings', skip: 'pending' },
'Update available, notes open': { path: '/settings', skip: 'pending' },
Downloading: { path: '/settings', skip: 'pending' },
Verifying: { path: '/settings', skip: 'pending' },
'Ready to install': { path: '/settings', skip: 'pending' },
'Restart: confirm': { path: '/settings', skip: 'pending' },
'Restart: request failed': { path: '/settings', skip: 'pending' },
'Restarting to install': { path: '/settings', skip: 'pending' },
'Restarting, taking longer': { path: '/settings', skip: 'pending' },
'Update done': { path: '/settings', skip: 'pending' },
'Update failed, rolled back': { path: '/settings', skip: 'pending' },
'Download failed': { path: '/settings', skip: 'pending' },
'Verification failed': { path: '/settings', skip: 'pending' },
'Too many requests (429)': {
path: '/settings',
api: updateApi('owner', updateNeverChecked(), {
status: 429,
headers: { 'Retry-After': '42' },
}),
},
'Not prepared': {
path: '/settings',
api: updateApi('owner', updateAvailable()),
},
'Not prepared: command copied': {
path: '/settings',
origin: integrationOrigin,
api: updateApi('owner', updateAvailable()),
steps: async (page) => {
await page.getByRole('button', { name: 'Copy', exact: true }).click()
await page.mouse.move(0, 0)
},
},
'Plain restart: confirm': { path: '/settings', skip: 'pending' },
'Plain restart: restarting': { path: '/settings', skip: 'pending' },
'Plain restart: done': { path: '/settings', skip: 'pending' },
'Admin view': {
path: '/settings',
api: updateApi('admin', updateAvailable()),
},
'Role changed (403)': {
path: '/settings',
api: {
...updateApi('owner', updateNeverChecked(), { status: 403 }),
'GET /v1/auth/me': {
status: 200,
bodyFor: (_url, call) => ({
...(verifiedSession.body as object),
role: call === 1 ? 'owner' : 'admin',
}),
},
'GET /v1/system/update': {
status: 200,
bodyFor: (_url, call) =>
call === 1 ? updateNeverChecked() : updateAvailable(),
},
},
},
'Update already running (409)': { path: '/settings', skip: 'pending' },
},
integration: {
Default: {
@@ -2222,6 +2339,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
},
},
@@ -2230,6 +2348,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
},
},
@@ -2238,6 +2357,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 200, body: [] },
},
},
@@ -2246,9 +2366,19 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
now: boardNow,
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
'GET /v1/article/': { status: 500 },
},
},
'Update available (owner)': {
path: '/home',
api: {
'GET /v1/auth/me': verifiedSession,
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
'GET /v1/system/update': { status: 200, body: updateAvailable() },
},
},
'Update ready to install (owner)': { path: '/home', skip: 'pending' },
},
'log-in': {
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },