feat: signed releases, release feed, notification (read-only)
This commit is contained in:
1 parent
a49eec909c
commit
834493a4f5
49 files changed
+4190
-17
No files matched your search
@@ -14,3 +14,4 @@ CORS_ALLOWED_ORIGINS=
|
|||||||
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
AUTH_RATE_LIMIT_MAX_ATTEMPTS=5
|
||||||
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
AUTH_RATE_LIMIT_WINDOW_SECONDS=60
|
||||||
TRUSTED_PROXIES=
|
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
|
- go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server
|
||||||
|
|
||||||
release:
|
release:
|
||||||
desc: Cross-compile production binaries for all release targets → bin/
|
desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY)
|
||||||
deps: [build:web]
|
|
||||||
cmds:
|
cmds:
|
||||||
|
- task: release:check
|
||||||
|
- task: build:web
|
||||||
- for: { var: RELEASE_TARGETS }
|
- for: { var: RELEASE_TARGETS }
|
||||||
task: build:target
|
task: build:target
|
||||||
vars:
|
vars:
|
||||||
GOOS: '{{index (splitList "/" .ITEM) 0}}'
|
GOOS: '{{index (splitList "/" .ITEM) 0}}'
|
||||||
GOARCH: '{{index (splitList "/" .ITEM) 1}}'
|
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:
|
build:target:
|
||||||
internal: true
|
internal: true
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import (
|
|||||||
domainImage "RenewCMS/internal/domain/image"
|
domainImage "RenewCMS/internal/domain/image"
|
||||||
domainInvite "RenewCMS/internal/domain/invite"
|
domainInvite "RenewCMS/internal/domain/invite"
|
||||||
domainMail "RenewCMS/internal/domain/mail"
|
domainMail "RenewCMS/internal/domain/mail"
|
||||||
|
domainUpdate "RenewCMS/internal/domain/update"
|
||||||
domainUser "RenewCMS/internal/domain/user"
|
domainUser "RenewCMS/internal/domain/user"
|
||||||
"RenewCMS/internal/infrastructure/config"
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"RenewCMS/internal/infrastructure/hashing"
|
"RenewCMS/internal/infrastructure/hashing"
|
||||||
"RenewCMS/internal/infrastructure/mailer"
|
"RenewCMS/internal/infrastructure/mailer"
|
||||||
"RenewCMS/internal/infrastructure/persistence/models"
|
"RenewCMS/internal/infrastructure/persistence/models"
|
||||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||||
|
"RenewCMS/internal/infrastructure/update"
|
||||||
"RenewCMS/internal/infrastructure/useCases"
|
"RenewCMS/internal/infrastructure/useCases"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/go-chi/jwtauth/v5"
|
"github.com/go-chi/jwtauth/v5"
|
||||||
@@ -51,6 +54,8 @@ type UseCases struct {
|
|||||||
MergeMediaUseCase *useCases.MergeMediaUseCase
|
MergeMediaUseCase *useCases.MergeMediaUseCase
|
||||||
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
|
GetMediaUsageUseCase *useCases.GetMediaUsageUseCase
|
||||||
GetSettingsUseCase *useCases.GetSettingsUseCase
|
GetSettingsUseCase *useCases.GetSettingsUseCase
|
||||||
|
GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase
|
||||||
|
CheckForUpdateUseCase *useCases.CheckForUpdateUseCase
|
||||||
}
|
}
|
||||||
|
|
||||||
type Repositories struct {
|
type Repositories struct {
|
||||||
@@ -59,9 +64,14 @@ type Repositories struct {
|
|||||||
ImageRepo domainImage.Repository
|
ImageRepo domainImage.Repository
|
||||||
InviteRepo domainInvite.Repository
|
InviteRepo domainInvite.Repository
|
||||||
MailRepo domainMail.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 {
|
func SQLiteDSN(dbName string) string {
|
||||||
return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs)
|
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),
|
ImageRepo: persistence.NewImageRepository(db, settings.UploadDir),
|
||||||
InviteRepo: persistence.NewInviteRepository(db),
|
InviteRepo: persistence.NewInviteRepository(db),
|
||||||
MailRepo: mailer.NewMailRepository(settings.Smtp),
|
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),
|
MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||||
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo),
|
||||||
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version),
|
||||||
|
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, settings.Version),
|
||||||
|
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 (
|
import (
|
||||||
"RenewCMS/api/pkg/apperr"
|
"RenewCMS/api/pkg/apperr"
|
||||||
|
"RenewCMS/api/pkg/claims"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -14,12 +15,25 @@ type rateLimitWindow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler {
|
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
|
var mu sync.Mutex
|
||||||
windows := make(map[string]*rateLimitWindow)
|
windows := make(map[string]*rateLimitWindow)
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if !allowed {
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||||
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
|
http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests)
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import (
|
|||||||
"RenewCMS/api/handlers/media"
|
"RenewCMS/api/handlers/media"
|
||||||
"RenewCMS/api/handlers/settings"
|
"RenewCMS/api/handlers/settings"
|
||||||
"RenewCMS/api/handlers/staticimages"
|
"RenewCMS/api/handlers/staticimages"
|
||||||
|
"RenewCMS/api/handlers/update"
|
||||||
"RenewCMS/api/handlers/user"
|
"RenewCMS/api/handlers/user"
|
||||||
"RenewCMS/api/middleware"
|
"RenewCMS/api/middleware"
|
||||||
"RenewCMS/internal/infrastructure/config"
|
"RenewCMS/internal/infrastructure/config"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/MadAppGang/httplog"
|
"github.com/MadAppGang/httplog"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -129,6 +131,29 @@ func NewSettingsRouter(container *UseCases) http.Handler {
|
|||||||
return r
|
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 {
|
func NewMediaRouter(container *UseCases) http.Handler {
|
||||||
r := chi.NewRouter()
|
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("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
r.Mount("/settings", NewSettingsRouter(container))
|
r.Mount("/settings", NewSettingsRouter(container))
|
||||||
r.Mount("/media", NewMediaRouter(container))
|
r.Mount("/media", NewMediaRouter(container))
|
||||||
|
r.Mount("/system", NewSystemRouter(container))
|
||||||
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies))
|
||||||
|
|
||||||
return r
|
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_MAX_ATTEMPTS": "5",
|
||||||
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
"AUTH_RATE_LIMIT_WINDOW_SECONDS": "60",
|
||||||
"TRUSTED_PROXIES": "",
|
"TRUSTED_PROXIES": "",
|
||||||
|
"UPDATE_FEED_URL": "https://git.floriansylvain.fr",
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
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_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. |
|
| `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). |
|
| `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)).
|
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 # 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.
|
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
|
## 2. Lay out the server
|
||||||
|
|
||||||
The following is a suggested layout for Linux:
|
The following is a suggested layout for Linux:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
- **Route:** `/settings`
|
- **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
|
- **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).
|
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
|
## 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 |
|
| 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
|
AuthRateLimit AuthRateLimitSettings
|
||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
Version string
|
Version string
|
||||||
|
UpdateFeedURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s Settings) IsProduction() bool {
|
func (s Settings) IsProduction() bool {
|
||||||
@@ -74,6 +75,7 @@ func NewSettingsFromEnv() (Settings, error) {
|
|||||||
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
|
Window: time.Duration(authRateLimitWindowSeconds) * time.Second,
|
||||||
},
|
},
|
||||||
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")),
|
||||||
|
UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"),
|
||||||
}, nil
|
}, 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 { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { useUpdateStore } from '@/stores/update'
|
||||||
import { useApi } from '@/composables/useApi'
|
import { useApi } from '@/composables/useApi'
|
||||||
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
|
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
|
||||||
import AppBoot from '@/components/AppBoot.vue'
|
import AppBoot from '@/components/AppBoot.vue'
|
||||||
@@ -9,6 +10,7 @@ import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
|
|||||||
import AppLogo from '@/components/AppLogo.vue'
|
import AppLogo from '@/components/AppLogo.vue'
|
||||||
import ErrorView from '@/components/ErrorView.vue'
|
import ErrorView from '@/components/ErrorView.vue'
|
||||||
import OfflineBanner from '@/components/OfflineBanner.vue'
|
import OfflineBanner from '@/components/OfflineBanner.vue'
|
||||||
|
import UpdatePill from '@/components/UpdatePill.vue'
|
||||||
import UserMenu from '@/components/UserMenu.vue'
|
import UserMenu from '@/components/UserMenu.vue'
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -43,6 +45,7 @@ const bodyUi = computed(() => {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const update = useUpdateStore()
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
|
|
||||||
const navItems = computed(() =>
|
const navItems = computed(() =>
|
||||||
@@ -52,7 +55,7 @@ const navItems = computed(() =>
|
|||||||
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
|
{ label: 'Media', to: '/media', icon: 'i-lucide-images' },
|
||||||
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
|
{ label: 'Users', to: '/users', icon: 'i-lucide-users' },
|
||||||
{ label: 'Integration', to: '/integration', icon: 'i-lucide-plug' },
|
{ 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) => ({
|
].map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
|
active: route.path === item.to || route.path.startsWith(`${item.to}/`),
|
||||||
@@ -154,7 +157,11 @@ async function onLogout() {
|
|||||||
:collapsed="sidebarCollapsed"
|
:collapsed="sidebarCollapsed"
|
||||||
aria-label="Main"
|
aria-label="Main"
|
||||||
:ui="navUi"
|
:ui="navUi"
|
||||||
/>
|
>
|
||||||
|
<template #settings-trailing>
|
||||||
|
<UpdatePill v-if="update.showCue && !sidebarCollapsed" />
|
||||||
|
</template>
|
||||||
|
</UNavigationMenu>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<UserMenu
|
<UserMenu
|
||||||
@@ -172,7 +179,6 @@ async function onLogout() {
|
|||||||
<UDashboardPanel :ui="{ body: bodyUi }">
|
<UDashboardPanel :ui="{ body: bodyUi }">
|
||||||
<template #header>
|
<template #header>
|
||||||
<UDashboardNavbar
|
<UDashboardNavbar
|
||||||
:toggle="{ ui: { leadingIcon: 'size-5' } }"
|
|
||||||
:title="pageTitle"
|
:title="pageTitle"
|
||||||
:ui="{
|
:ui="{
|
||||||
root: 'h-14 gap-1 bg-default px-1.5 sm:px-1.5 lg:h-12 lg:gap-2 lg:px-6',
|
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',
|
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>
|
<template #leading>
|
||||||
<UDashboardSidebarCollapse
|
<UDashboardSidebarCollapse
|
||||||
color="neutral"
|
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()
|
return check()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
const { getMe } = useApi()
|
||||||
|
try {
|
||||||
|
session.value = await getMe()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
session.value = null
|
session.value = null
|
||||||
status.value = 'ready'
|
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">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from '@nuxt/ui/composables'
|
import { useToast } from '@nuxt/ui/composables'
|
||||||
import DashboardShell from '@/components/DashboardShell.vue'
|
import DashboardShell from '@/components/DashboardShell.vue'
|
||||||
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
import ArticleStatusBadge from '@/components/ArticleStatusBadge.vue'
|
||||||
import NewArticleModal from '@/components/NewArticleModal.vue'
|
import NewArticleModal from '@/components/NewArticleModal.vue'
|
||||||
|
import UpdateBanner from '@/components/UpdateBanner.vue'
|
||||||
import { useArticles, type Article } from '@/composables/useArticles'
|
import { useArticles, type Article } from '@/composables/useArticles'
|
||||||
import { useSessionStore } from '@/stores/session'
|
import { useSessionStore } from '@/stores/session'
|
||||||
|
import { useUpdateStore } from '@/stores/update'
|
||||||
import { accountName } from '@/lib/accountName'
|
import { accountName } from '@/lib/accountName'
|
||||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||||
|
import { displayVersion } from '@/lib/updateFormat'
|
||||||
|
|
||||||
const SKELETON_DELAY_MS = 150
|
const SKELETON_DELAY_MS = 150
|
||||||
const RECENT_LIST_SIZE = 5
|
const RECENT_LIST_SIZE = 5
|
||||||
@@ -18,6 +21,7 @@ type LoadState = 'loading' | 'default' | 'empty' | 'error'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const session = useSessionStore()
|
const session = useSessionStore()
|
||||||
|
const update = useUpdateStore()
|
||||||
const { listArticles } = useArticles()
|
const { listArticles } = useArticles()
|
||||||
|
|
||||||
const loadState = ref<LoadState>('loading')
|
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)
|
onMounted(loadArticles)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -145,6 +159,14 @@ onMounted(loadArticles)
|
|||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</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'">
|
<template v-if="(loadState === 'loading' && showSkeleton) || loadState === 'default'">
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="grid grid-cols-3 gap-3">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<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 DashboardShell from '@/components/DashboardShell.vue'
|
||||||
|
import UpdatesCard from '@/components/UpdatesCard.vue'
|
||||||
import { ApiError } from '@/composables/useApi'
|
import { ApiError } from '@/composables/useApi'
|
||||||
|
import { useRetryAfter } from '@/composables/useRetryAfter'
|
||||||
import { useSettings } from '@/composables/useSettings'
|
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 SKELETON_DELAY_MS = 150
|
||||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||||
@@ -10,6 +17,10 @@ const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try
|
|||||||
type LoadState = 'loading' | 'ready' | 'error'
|
type LoadState = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
const { getSettings } = useSettings()
|
const { getSettings } = useSettings()
|
||||||
|
const toast = useToast()
|
||||||
|
const session = useSessionStore()
|
||||||
|
const update = useUpdateStore()
|
||||||
|
const retry = useRetryAfter()
|
||||||
|
|
||||||
const loadState = ref<LoadState>('loading')
|
const loadState = ref<LoadState>('loading')
|
||||||
const version = ref('')
|
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)
|
onBeforeUnmount(clearSkeletonTimer)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DashboardShell>
|
<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">
|
<div class="flex flex-col gap-0.5">
|
||||||
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
|
<h1 class="text-xl font-semibold text-highlighted">Settings</h1>
|
||||||
<p class="text-muted">Workspace information.</p>
|
<p class="text-muted">Workspace information.</p>
|
||||||
@@ -106,6 +192,16 @@ onBeforeUnmount(clearSkeletonTimer)
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</DashboardShell>
|
</DashboardShell>
|
||||||
</template>
|
</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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+133
-3
@@ -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 editorNow = '2026-09-26T11:02:00Z'
|
||||||
|
|
||||||
const bathroomBody =
|
const bathroomBody =
|
||||||
@@ -1981,14 +2040,72 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
'Invite sent': { path: '/users', skip: 'clock-sensitive' },
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
Default: {
|
'Up to date': { path: '/settings', skip: 'clock-sensitive' },
|
||||||
|
Checking: {
|
||||||
|
path: '/settings',
|
||||||
|
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',
|
path: '/settings',
|
||||||
api: {
|
api: {
|
||||||
'GET /v1/auth/me': verifiedSession,
|
...updateApi('owner', updateNeverChecked(), { status: 403 }),
|
||||||
'GET /v1/settings/': { status: 200, body: { version: 'v2.2.0-beta' } },
|
'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: {
|
integration: {
|
||||||
Default: {
|
Default: {
|
||||||
path: '/integration',
|
path: '/integration',
|
||||||
@@ -2222,6 +2339,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
now: boardNow,
|
now: boardNow,
|
||||||
api: {
|
api: {
|
||||||
'GET /v1/auth/me': verifiedSession,
|
'GET /v1/auth/me': verifiedSession,
|
||||||
|
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
'GET /v1/article/': { status: 200, body: sixArticlesFixture },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2230,6 +2348,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
now: boardNow,
|
now: boardNow,
|
||||||
api: {
|
api: {
|
||||||
'GET /v1/auth/me': verifiedSession,
|
'GET /v1/auth/me': verifiedSession,
|
||||||
|
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||||
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
|
'GET /v1/article/': { status: 200, body: sixArticlesFixture, hang: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2238,6 +2357,7 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
now: boardNow,
|
now: boardNow,
|
||||||
api: {
|
api: {
|
||||||
'GET /v1/auth/me': verifiedSession,
|
'GET /v1/auth/me': verifiedSession,
|
||||||
|
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||||
'GET /v1/article/': { status: 200, body: [] },
|
'GET /v1/article/': { status: 200, body: [] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2246,9 +2366,19 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
|
|||||||
now: boardNow,
|
now: boardNow,
|
||||||
api: {
|
api: {
|
||||||
'GET /v1/auth/me': verifiedSession,
|
'GET /v1/auth/me': verifiedSession,
|
||||||
|
'GET /v1/system/update': { status: 200, body: updateStatusBody() },
|
||||||
'GET /v1/article/': { status: 500 },
|
'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': {
|
'log-in': {
|
||||||
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
Default: { path: '/login', api: { 'GET /v1/auth/registration': registrationClosed } },
|
||||||
|
|||||||
Reference in new issue
Block a user