feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4191
-18
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 = ¬es
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 } },
|
||||
|
||||
Reference in new issue
Block a user