diff --git a/.env.example b/.env.example index e4ad931..e97f4e4 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,4 @@ CORS_ALLOWED_ORIGINS= AUTH_RATE_LIMIT_MAX_ATTEMPTS=5 AUTH_RATE_LIMIT_WINDOW_SECONDS=60 TRUSTED_PROXIES= +UPDATE_FEED_URL=https://git.floriansylvain.fr diff --git a/Taskfile.yml b/Taskfile.yml index 025f48a..554a56e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -122,14 +122,37 @@ tasks: - go build -trimpath -ldflags "{{.LDFLAGS}}" -o bin/{{.BINARY}}{{exeExt}} ./cmd/server release: - desc: Cross-compile production binaries for all release targets → bin/ - deps: [build:web] + desc: Cross-compile and sign production binaries for all release targets → bin/ (needs RELEASE_SIGNING_KEY) cmds: + - task: release:check + - task: build:web - for: { var: RELEASE_TARGETS } task: build:target vars: GOOS: '{{index (splitList "/" .ITEM) 0}}' GOARCH: '{{index (splitList "/" .ITEM) 1}}' + - go run ./cmd/releasesign checksums bin --match "{{.BINARY}}_{{.VERSION}}_*" + - go run ./cmd/releasesign sign bin/checksums.txt + - go run ./cmd/releasesign verify bin/checksums.txt + + release:check: + internal: true + cmds: + - cmd: 'test -n "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY is not set. Point it at the private release key file (see task release:keygen)." >&2; exit 1; }' + silent: true + - cmd: 'test -f "$RELEASE_SIGNING_KEY" || { echo "RELEASE_SIGNING_KEY points to a missing file: $RELEASE_SIGNING_KEY" >&2; exit 1; }' + silent: true + - mkdir -p tmp + - echo probe > tmp/release-probe.txt + - defer: rm -f tmp/release-probe.txt tmp/release-probe.txt.sig + - go run ./cmd/releasesign sign tmp/release-probe.txt + + release:keygen: + desc: One-time release key pair. Usage, task release:keygen -- + cmds: + - cmd: 'test -n "{{.CLI_ARGS}}" || { echo "usage: task release:keygen -- " >&2; exit 1; }' + silent: true + - go run ./cmd/releasesign keygen {{.CLI_ARGS}} build:target: internal: true diff --git a/api/dependecyInjection.go b/api/dependecyInjection.go index e181870..18ddbc7 100644 --- a/api/dependecyInjection.go +++ b/api/dependecyInjection.go @@ -5,16 +5,19 @@ import ( domainImage "RenewCMS/internal/domain/image" domainInvite "RenewCMS/internal/domain/invite" domainMail "RenewCMS/internal/domain/mail" + domainUpdate "RenewCMS/internal/domain/update" domainUser "RenewCMS/internal/domain/user" "RenewCMS/internal/infrastructure/config" "RenewCMS/internal/infrastructure/hashing" "RenewCMS/internal/infrastructure/mailer" "RenewCMS/internal/infrastructure/persistence/models" persistence "RenewCMS/internal/infrastructure/persistence/repositories" + "RenewCMS/internal/infrastructure/update" "RenewCMS/internal/infrastructure/useCases" "fmt" "os" "path/filepath" + "time" "github.com/glebarez/sqlite" "github.com/go-chi/jwtauth/v5" @@ -51,6 +54,8 @@ type UseCases struct { MergeMediaUseCase *useCases.MergeMediaUseCase GetMediaUsageUseCase *useCases.GetMediaUsageUseCase GetSettingsUseCase *useCases.GetSettingsUseCase + GetUpdateStatusUseCase *useCases.GetUpdateStatusUseCase + CheckForUpdateUseCase *useCases.CheckForUpdateUseCase } type Repositories struct { @@ -59,9 +64,14 @@ type Repositories struct { ImageRepo domainImage.Repository InviteRepo domainInvite.Repository MailRepo domainMail.Repository + ReleaseFeed domainUpdate.ReleaseFeed + UpdateStore domainUpdate.StatusStore } -const sqliteBusyTimeoutMs = 5000 +const ( + sqliteBusyTimeoutMs = 5000 + updateStatusFileName = "update-status.json" +) func SQLiteDSN(dbName string) string { return fmt.Sprintf("%s?_pragma=busy_timeout(%d)", dbName, sqliteBusyTimeoutMs) @@ -98,6 +108,8 @@ func getRepositories(db *gorm.DB, settings config.Settings) *Repositories { ImageRepo: persistence.NewImageRepository(db, settings.UploadDir), InviteRepo: persistence.NewInviteRepository(db), MailRepo: mailer.NewMailRepository(settings.Smtp), + ReleaseFeed: update.NewGiteaReleaseFeed(settings.UpdateFeedURL), + UpdateStore: update.NewJSONStatusStore(filepath.Join(filepath.Dir(settings.DBFile), updateStatusFileName)), } } @@ -134,6 +146,8 @@ func getUseCases(repos *Repositories, settings config.Settings) *UseCases { MergeMediaUseCase: useCases.NewMergeMediaUseCase(repos.ImageRepo, repos.ArticleRepo), GetMediaUsageUseCase: useCases.NewGetMediaUsageUseCase(repos.ImageRepo, repos.ArticleRepo), GetSettingsUseCase: useCases.NewGetSettingsUseCase(settings.Version), + GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(repos.UpdateStore, settings.Version), + CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(repos.ReleaseFeed, repos.UpdateStore, settings.Version, time.Now), } } diff --git a/api/handlers/update/updateHandler.go b/api/handlers/update/updateHandler.go new file mode 100644 index 0000000..7ccee24 --- /dev/null +++ b/api/handlers/update/updateHandler.go @@ -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) +} diff --git a/api/middleware/rateLimit.go b/api/middleware/rateLimit.go index 18347d6..af7cdd3 100644 --- a/api/middleware/rateLimit.go +++ b/api/middleware/rateLimit.go @@ -2,6 +2,7 @@ package middleware import ( "RenewCMS/api/pkg/apperr" + "RenewCMS/api/pkg/claims" "net/http" "strconv" "sync" @@ -14,12 +15,25 @@ type rateLimitWindow struct { } func NewRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler { + return newKeyedRateLimiter(maxAttempts, window, func(r *http.Request) string { return r.RemoteAddr }) +} + +func NewUserRateLimiter(maxAttempts int, window time.Duration) func(http.Handler) http.Handler { + return newKeyedRateLimiter(maxAttempts, window, userRateLimitKey) +} + +func userRateLimitKey(r *http.Request) string { + userId, _ := claims.UserId(r) + return strconv.FormatUint(uint64(userId), 10) +} + +func newKeyedRateLimiter(maxAttempts int, window time.Duration, keyOf func(*http.Request) string) func(http.Handler) http.Handler { var mu sync.Mutex windows := make(map[string]*rateLimitWindow) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - retryAfter, allowed := recordAttempt(&mu, windows, r.RemoteAddr, maxAttempts, window) + retryAfter, allowed := recordAttempt(&mu, windows, keyOf(r), maxAttempts, window) if !allowed { w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) http.Error(w, apperr.TooManyRequestsErrorMessage, http.StatusTooManyRequests) diff --git a/api/router.go b/api/router.go index 0352af8..654d7c7 100644 --- a/api/router.go +++ b/api/router.go @@ -7,11 +7,13 @@ import ( "RenewCMS/api/handlers/media" "RenewCMS/api/handlers/settings" "RenewCMS/api/handlers/staticimages" + "RenewCMS/api/handlers/update" "RenewCMS/api/handlers/user" "RenewCMS/api/middleware" "RenewCMS/internal/infrastructure/config" "encoding/json" "net/http" + "time" "github.com/MadAppGang/httplog" "github.com/go-chi/chi/v5" @@ -129,6 +131,29 @@ func NewSettingsRouter(container *UseCases) http.Handler { return r } +const ( + updateCheckMaxAttempts = 5 + updateCheckWindow = time.Minute +) + +func NewSystemRouter(container *UseCases) http.Handler { + r := chi.NewRouter() + + updateHandler := update.NewHandler(container.GetUpdateStatusUseCase, container.CheckForUpdateUseCase) + + r.Use(jwtauth.Verifier(container.Token)) + r.Use(jwtauth.Authenticator(container.Token)) + r.Use(middleware.RequireCurrentToken(container.GetUserUseCase)) + r.Use(middleware.RequireVerifiedUser(container.GetUserUseCase)) + r.Get("/update", updateHandler.GetStatus) + r.Group(func(r chi.Router) { + r.Use(middleware.RequireOwner(container.GetUserUseCase)) + r.Use(middleware.NewUserRateLimiter(updateCheckMaxAttempts, updateCheckWindow)) + r.Post("/update/check", updateHandler.Check) + }) + return r +} + func NewMediaRouter(container *UseCases) http.Handler { r := chi.NewRouter() @@ -162,6 +187,7 @@ func InitBackendRoutes(container *UseCases, settings config.Settings) *chi.Mux { r.Mount("/user", NewUserRouter(container, settings.AuthRateLimit, settings.TrustedProxies)) r.Mount("/settings", NewSettingsRouter(container)) r.Mount("/media", NewMediaRouter(container)) + r.Mount("/system", NewSystemRouter(container)) r.Mount("/auth", NewAuthRouter(container, settings.IsProduction(), settings.AuthRateLimit, settings.TrustedProxies)) return r diff --git a/cmd/releasesign/main.go b/cmd/releasesign/main.go new file mode 100644 index 0000000..ebe40fe --- /dev/null +++ b/cmd/releasesign/main.go @@ -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)) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 04d1868..78ea27f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -47,6 +47,7 @@ var envDefaults = map[string]string{ "AUTH_RATE_LIMIT_MAX_ATTEMPTS": "5", "AUTH_RATE_LIMIT_WINDOW_SECONDS": "60", "TRUSTED_PROXIES": "", + "UPDATE_FEED_URL": "https://git.floriansylvain.fr", } func main() { diff --git a/docs/configuration.md b/docs/configuration.md index f78423d..5560557 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,6 +30,7 @@ Copy [`.env.example`](../.env.example) to `.env` to get started. When a required | `AUTH_RATE_LIMIT_MAX_ATTEMPTS` | `5` | Maximum number of requests a single client IP may send to `POST /v1/auth/login`, `POST /v1/auth/password-reset` or the invite-accept pair (`GET /v1/auth/invite` and `POST /v1/auth/invite/accept`, sharing one counter) within the window, tracked independently per route. | | `AUTH_RATE_LIMIT_WINDOW_SECONDS` | `60` | Length, in seconds, of the fixed rate-limit window for the routes above. | | `TRUSTED_PROXIES` | *(empty)* | Semicolon-separated list of exact IP addresses (no CIDR) of reverse proxies allowed to supply the client IP via `X-Real-IP` for the rate limiting of the routes above. When a request's connection address is not in this list, any `X-Real-IP` or `X-Forwarded-For` header it sends is ignored and the connection address is used instead. See [deployment.md](deployment.md). | +| `UPDATE_FEED_URL` | `https://git.floriansylvain.fr` | Base URL of the Gitea server that publishes RenewCMS releases. The Settings page asks `{UPDATE_FEED_URL}/api/v1/repos/fsylvain/RenewCMS/releases` when the owner checks for updates. The last result is kept in `update-status.json`, next to `DB_FILE`. | Relative paths are resolved against the process's working directory. In production, use absolute paths (see [deployment.md](deployment.md)). diff --git a/docs/deployment.md b/docs/deployment.md index 0e3d863..af831a2 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -19,8 +19,22 @@ task build # this OS/arch → bin/renewcms[.exe] task release # all targets → bin/renewcms___[.exe] ``` +`task release` also signs the release: it writes `bin/checksums.txt` (SHA-256 of the six binaries) and signs it into `bin/checksums.txt.sig` with an Ed25519 key. It refuses to start, before building anything, unless `RELEASE_SIGNING_KEY` points to the private key file and that key matches the public key committed as `internal/infrastructure/update/release.pub`. The one-time setup is below. + The frontend is built once and embedded into every binary. The binaries are static (`CGO_ENABLED=0`), so a Linux build runs on any distribution. The version comes from `git describe`: tag a release (`git tag v1.2.0`) before building, and the binary logs its version at startup. +### Release signing key (maintainers, once) + +1. Run `task release:keygen -- ` 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=` before every `task release`. + +To check a download by hand: `go run ./cmd/releasesign verify checksums.txt`, with `checksums.txt.sig` next to it, then compare the binary's hash with the matching line of `checksums.txt`. + +### Update checks + +The owner's Settings page asks the release feed for the newest release (see `UPDATE_FEED_URL` in [configuration.md](configuration.md)) and keeps the last result in `update-status.json`, next to `DB_FILE`. Back it up with the rest of the data directory if you like; it is rebuilt by the next check when missing. The Settings page only reports a newer version for now: the site does not update itself. + ## 2. Lay out the server The following is a suggested layout for Linux: diff --git a/docs/design/screens/settings.md b/docs/design/screens/settings.md index cda4019..c2abc93 100644 --- a/docs/design/screens/settings.md +++ b/docs/design/screens/settings.md @@ -2,7 +2,7 @@ - **Route:** `/settings` - **API:** GET /v1/settings (version) · GET /v1/system/update · POST /v1/system/update/check · POST /v1/system/update/download · POST /v1/system/update/restart -- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings: Updates", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). +- **Visual reference:** `source/RenewCMS Screens Back-office.dc.html`, group "Settings", drawn by `source/AppFrame.dc.html` (open it in a browser; each state is drawn at desktop and mobile, with a light/dark toggle). Opened from the sidebar (Settings, settings icon, after Integration) and from the mobile drawer. Every verified user sees it. It holds two sections: Version (everyone) and Updates (the owner has the buttons, admins read the status). diff --git a/docs/release-notes.md b/docs/release-notes.md index f0af9ea..4146f96 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -17,7 +17,7 @@ Rules: bullets are full sentences ending with a period; user-facing wording, no ## Published releases -Each body lives in `docs/releases/.md`. Publish with `tea releases create --tag --title "" --note-file docs/releases/.md --prerelease --asset ` (one `--asset` per binary), after `task release`. +Each body lives in `docs/releases/.md`. Publish with `tea releases create --tag --title "" --note-file docs/releases/.md --prerelease --asset ` 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 | | --- | --- | diff --git a/features/updates_release_feed.feature b/features/updates_release_feed.feature new file mode 100644 index 0000000..a4b551b --- /dev/null +++ b/features/updates_release_feed.feature @@ -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 [--pub-out ], sign [--key ] [--pub ], + verify [--pub ] + 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 "" and the newest feed release is "" + When the versions are compared + Then available is + + 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 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 + When task release is run + Then it fails before building any binary with a message naming + 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 diff --git a/features/updates_release_feed.qa.md b/features/updates_release_feed.qa.md new file mode 100644 index 0000000..16bf4f6 --- /dev/null +++ b/features/updates_release_feed.qa.md @@ -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. diff --git a/internal/domain/update/updateDomain.go b/internal/domain/update/updateDomain.go new file mode 100644 index 0000000..3e76098 --- /dev/null +++ b/internal/domain/update/updateDomain.go @@ -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 +} diff --git a/internal/domain/update/updateNotes.go b/internal/domain/update/updateNotes.go new file mode 100644 index 0000000..d03deee --- /dev/null +++ b/internal/domain/update/updateNotes.go @@ -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) +} diff --git a/internal/domain/update/updateRepositoryInterface.go b/internal/domain/update/updateRepositoryInterface.go new file mode 100644 index 0000000..21b812f --- /dev/null +++ b/internal/domain/update/updateRepositoryInterface.go @@ -0,0 +1,10 @@ +package update + +type ReleaseFeed interface { + Latest() (Release, error) +} + +type StatusStore interface { + Load() (CheckResult, bool) + Save(result CheckResult) error +} diff --git a/internal/domain/update/updateVersion.go b/internal/domain/update/updateVersion.go new file mode 100644 index 0000000..22e84c3 --- /dev/null +++ b/internal/domain/update/updateVersion.go @@ -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) +} diff --git a/internal/infrastructure/config/settings.go b/internal/infrastructure/config/settings.go index 25785aa..72f9978 100644 --- a/internal/infrastructure/config/settings.go +++ b/internal/infrastructure/config/settings.go @@ -32,6 +32,7 @@ type Settings struct { AuthRateLimit AuthRateLimitSettings TrustedProxies []string Version string + UpdateFeedURL string } func (s Settings) IsProduction() bool { @@ -74,6 +75,7 @@ func NewSettingsFromEnv() (Settings, error) { Window: time.Duration(authRateLimitWindowSeconds) * time.Second, }, TrustedProxies: parseSemicolonSeparatedList(os.Getenv("TRUSTED_PROXIES")), + UpdateFeedURL: os.Getenv("UPDATE_FEED_URL"), }, nil } diff --git a/internal/infrastructure/update/giteaReleaseFeed.go b/internal/infrastructure/update/giteaReleaseFeed.go new file mode 100644 index 0000000..d7044a9 --- /dev/null +++ b/internal/infrastructure/update/giteaReleaseFeed.go @@ -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 +} diff --git a/internal/infrastructure/update/jsonStatusStore.go b/internal/infrastructure/update/jsonStatusStore.go new file mode 100644 index 0000000..fe8a593 --- /dev/null +++ b/internal/infrastructure/update/jsonStatusStore.go @@ -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) +} diff --git a/internal/infrastructure/update/publicKey.go b/internal/infrastructure/update/publicKey.go new file mode 100644 index 0000000..c2888be --- /dev/null +++ b/internal/infrastructure/update/publicKey.go @@ -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) +} diff --git a/internal/infrastructure/update/release.pub b/internal/infrastructure/update/release.pub new file mode 100644 index 0000000..1ef02f8 --- /dev/null +++ b/internal/infrastructure/update/release.pub @@ -0,0 +1 @@ +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= diff --git a/internal/infrastructure/update/releasesign/releasesign.go b/internal/infrastructure/update/releasesign/releasesign.go new file mode 100644 index 0000000..d3f1518 --- /dev/null +++ b/internal/infrastructure/update/releasesign/releasesign.go @@ -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 [--pub-out ] | sign [--key ] [--pub ] | verify [--pub ] | checksums [--match ]" + 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) +} diff --git a/internal/infrastructure/useCases/CheckForUpdate.go b/internal/infrastructure/useCases/CheckForUpdate.go new file mode 100644 index 0000000..08adeef --- /dev/null +++ b/internal/infrastructure/useCases/CheckForUpdate.go @@ -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 +} diff --git a/internal/infrastructure/useCases/GetUpdateStatus.go b/internal/infrastructure/useCases/GetUpdateStatus.go new file mode 100644 index 0000000..95fe41e --- /dev/null +++ b/internal/infrastructure/useCases/GetUpdateStatus.go @@ -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) +} diff --git a/test/releasesign_test.go b/test/releasesign_test.go new file mode 100644 index 0000000..600a6ce --- /dev/null +++ b/test/releasesign_test.go @@ -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") + } + }) +} diff --git a/test/update_feed_test.go b/test/update_feed_test.go new file mode 100644 index 0000000..472f10e --- /dev/null +++ b/test/update_feed_test.go @@ -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) + } +} diff --git a/test/update_http_test.go b/test/update_http_test.go new file mode 100644 index 0000000..31daed1 --- /dev/null +++ b/test/update_http_test.go @@ -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) + } + } + }) +} diff --git a/test/update_test.go b/test/update_test.go new file mode 100644 index 0000000..d483f7c --- /dev/null +++ b/test/update_test.go @@ -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) + } + }) +} diff --git a/web/src/components/DashboardShell.vue b/web/src/components/DashboardShell.vue index 96b5977..821e1c8 100644 --- a/web/src/components/DashboardShell.vue +++ b/web/src/components/DashboardShell.vue @@ -2,6 +2,7 @@ import { computed, nextTick, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useSessionStore } from '@/stores/session' +import { useUpdateStore } from '@/stores/update' import { useApi } from '@/composables/useApi' import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed' import AppBoot from '@/components/AppBoot.vue' @@ -9,6 +10,7 @@ import AccountMenuSheet from '@/components/AccountMenuSheet.vue' import AppLogo from '@/components/AppLogo.vue' import ErrorView from '@/components/ErrorView.vue' import OfflineBanner from '@/components/OfflineBanner.vue' +import UpdatePill from '@/components/UpdatePill.vue' import UserMenu from '@/components/UserMenu.vue' const props = withDefaults( @@ -43,6 +45,7 @@ const bodyUi = computed(() => { const route = useRoute() const router = useRouter() const session = useSessionStore() +const update = useUpdateStore() const api = useApi() const navItems = computed(() => @@ -52,7 +55,7 @@ const navItems = computed(() => { label: 'Media', to: '/media', icon: 'i-lucide-images' }, { label: 'Users', to: '/users', icon: 'i-lucide-users' }, { label: 'Integration', to: '/integration', icon: 'i-lucide-plug' }, - { label: 'Settings', to: '/settings', icon: 'i-lucide-settings' }, + { label: 'Settings', to: '/settings', icon: 'i-lucide-settings', slot: 'settings' }, ].map((item) => ({ ...item, active: route.path === item.to || route.path.startsWith(`${item.to}/`), @@ -154,7 +157,11 @@ async function onLogout() { :collapsed="sidebarCollapsed" aria-label="Main" :ui="navUi" - /> + > + +