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

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

No files matched your search

+312
View File
@@ -0,0 +1,312 @@
package test
import (
"RenewCMS/internal/infrastructure/update/releasesign"
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
const placeholderPublicKey = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
type signingKeyPair struct {
privatePath string
publicPath string
}
func runReleaseSign(args ...string) (int, string) {
var stdout, stderr bytes.Buffer
code := releasesign.Run(args, &stdout, &stderr)
return code, stdout.String() + stderr.String()
}
func newSigningKeyPair(t *testing.T, dir string) signingKeyPair {
t.Helper()
pair := signingKeyPair{privatePath: filepath.Join(dir, "release.key"), publicPath: filepath.Join(dir, "release.pub")}
if code, output := runReleaseSign("keygen", pair.privatePath, "--pub-out", pair.publicPath); code != 0 {
t.Fatalf("keygen failed: %s", output)
}
return pair
}
func writeChecksums(t *testing.T, dir string) string {
t.Helper()
path := filepath.Join(dir, "checksums.txt")
if err := os.WriteFile(path, []byte("abc renewcms_v1_linux_amd64\ndef renewcms_v1_darwin_arm64\n"), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestReleaseSigningRoundTrip(t *testing.T) {
t.Run("Release signing round trip with a throwaway key", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
file := writeChecksums(t, dir)
if code, output := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath); code != 0 {
t.Fatalf("sign failed: %s", output)
}
if code, output := runReleaseSign("verify", file, "--pub", pair.publicPath); code != 0 {
t.Fatalf("verify failed: %s", output)
}
content, _ := os.ReadFile(file)
content[0] ^= 0x01
if err := os.WriteFile(file, content, 0o600); err != nil {
t.Fatal(err)
}
code, output := runReleaseSign("verify", file, "--pub", pair.publicPath)
if code == 0 || !strings.Contains(output, "signature does not match") {
t.Errorf("a changed file must fail verify with a clear message: %d %q", code, output)
}
})
t.Run("verify with a different public key fails", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
other := newSigningKeyPair(t, t.TempDir())
file := writeChecksums(t, dir)
runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath)
if code, _ := runReleaseSign("verify", file, "--pub", other.publicPath); code == 0 {
t.Error("verify with another public key must fail")
}
})
t.Run("sign takes the private key from RELEASE_SIGNING_KEY", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
file := writeChecksums(t, dir)
t.Setenv("RELEASE_SIGNING_KEY", pair.privatePath)
if code, output := runReleaseSign("sign", file, "--pub", pair.publicPath); code != 0 {
t.Fatalf("sign failed: %s", output)
}
if code, output := runReleaseSign("verify", file, "--pub", pair.publicPath); code != 0 {
t.Fatalf("verify failed: %s", output)
}
})
t.Run("sign without any key says so", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
t.Setenv("RELEASE_SIGNING_KEY", "")
code, output := runReleaseSign("sign", writeChecksums(t, dir), "--pub", pair.publicPath)
if code == 0 || !strings.Contains(output, "RELEASE_SIGNING_KEY") {
t.Errorf("code = %d output = %q", code, output)
}
})
t.Run("sign with a missing key file names the path", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
missing := filepath.Join(dir, "missing.key")
code, output := runReleaseSign("sign", writeChecksums(t, dir), "--key", missing, "--pub", pair.publicPath)
if code == 0 || !strings.Contains(output, missing) {
t.Errorf("code = %d output = %q", code, output)
}
})
}
func TestReleaseSignKeygen(t *testing.T) {
t.Run("keygen refuses a path inside the repository", func(t *testing.T) {
repoRoot, err := filepath.Abs("..")
if err != nil {
t.Fatal(err)
}
inside := filepath.Join(repoRoot, "tmp", "releasesign-test.key")
t.Cleanup(func() { os.Remove(inside) })
code, output := runReleaseSign("keygen", inside)
if code == 0 || !strings.Contains(output, "inside the repository") {
t.Errorf("code = %d output = %q", code, output)
}
if _, err := os.Stat(inside); err == nil {
t.Error("nothing may be written inside the repository")
}
})
t.Run("keygen never overwrites an existing key", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
before, _ := os.ReadFile(pair.privatePath)
code, _ := runReleaseSign("keygen", pair.privatePath)
after, _ := os.ReadFile(pair.privatePath)
if code == 0 || !bytes.Equal(before, after) {
t.Errorf("code = %d, key changed = %v", code, !bytes.Equal(before, after))
}
})
t.Run("keygen prints the public key and where to commit it", func(t *testing.T) {
dir := t.TempDir()
code, output := runReleaseSign("keygen", filepath.Join(dir, "k"))
if code != 0 || !strings.Contains(output, "internal/infrastructure/update/release.pub") {
t.Errorf("code = %d output = %q", code, output)
}
})
}
func TestReleaseSignPlaceholderKey(t *testing.T) {
t.Run("The placeholder public key is refused", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
placeholder := filepath.Join(dir, "placeholder.pub")
if err := os.WriteFile(placeholder, []byte(placeholderPublicKey+"\n"), 0o600); err != nil {
t.Fatal(err)
}
file := writeChecksums(t, dir)
signCode, signOutput := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", placeholder)
verifyCode, verifyOutput := runReleaseSign("verify", file, "--pub", placeholder)
for _, output := range []string{signOutput, verifyOutput} {
if !strings.Contains(output, "placeholder public key has not been replaced") {
t.Errorf("output = %q", output)
}
}
if signCode == 0 || verifyCode == 0 {
t.Errorf("sign = %d verify = %d", signCode, verifyCode)
}
if _, err := os.Stat(file + ".sig"); err == nil {
t.Error("no .sig may be written")
}
})
t.Run("a malformed public key is refused", func(t *testing.T) {
dir := t.TempDir()
bad := filepath.Join(dir, "bad.pub")
_ = os.WriteFile(bad, []byte("not base64!"), 0o600)
if code, _ := runReleaseSign("verify", writeChecksums(t, dir), "--pub", bad); code == 0 {
t.Error("a malformed key must fail")
}
})
}
func TestReleaseSignKeyMismatch(t *testing.T) {
t.Run("sign refuses a private key that does not match the public key", func(t *testing.T) {
dir := t.TempDir()
signer := newSigningKeyPair(t, dir)
other := newSigningKeyPair(t, t.TempDir())
file := writeChecksums(t, dir)
code, output := runReleaseSign("sign", file, "--key", signer.privatePath, "--pub", other.publicPath)
if code == 0 || !strings.Contains(output, "does not match") {
t.Errorf("code = %d output = %q", code, output)
}
if _, err := os.Stat(file + ".sig"); err == nil {
t.Error("no .sig may be written")
}
})
}
func TestReleaseSignUsage(t *testing.T) {
for name, args := range map[string][]string{
"no command": nil,
"unknown command": {"explode"},
"missing file": {"verify"},
"unknown flag": {"verify", "x", "--nope", "1"},
} {
t.Run(name, func(t *testing.T) {
if code, _ := runReleaseSign(args...); code == 0 {
t.Error("expected a non-zero exit")
}
})
}
}
func TestReleaseSignChecksums(t *testing.T) {
dir := t.TempDir()
for name, content := range map[string]string{
"renewcms_v2.5.0_linux_amd64": "linux",
"renewcms_v2.5.0_windows_amd64.exe": "windows",
"renewcms_v2.4.0_linux_amd64": "old",
} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
t.Run("writes the SHA-256 of the matching binaries only", func(t *testing.T) {
code, output := runReleaseSign("checksums", dir, "--match", "renewcms_v2.5.0_*")
if code != 0 {
t.Fatalf("checksums failed: %s", output)
}
content, err := os.ReadFile(filepath.Join(dir, "checksums.txt"))
if err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(string(content)), "\n")
if len(lines) != 2 || !strings.HasSuffix(lines[0], " renewcms_v2.5.0_linux_amd64") || !strings.HasSuffix(lines[1], " renewcms_v2.5.0_windows_amd64.exe") {
t.Fatalf("checksums.txt = %q", content)
}
sum := sha256.Sum256([]byte("linux"))
if want := hex.EncodeToString(sum[:]) + " renewcms_v2.5.0_linux_amd64"; lines[0] != want {
t.Errorf("line = %q, want %q", lines[0], want)
}
})
t.Run("a second run does not list checksums.txt or its signature", func(t *testing.T) {
runReleaseSign("checksums", dir, "--match", "*")
content, _ := os.ReadFile(filepath.Join(dir, "checksums.txt"))
if strings.Contains(string(content), "checksums.txt") {
t.Errorf("checksums.txt lists itself: %q", content)
}
})
t.Run("no match is an error", func(t *testing.T) {
if code, _ := runReleaseSign("checksums", dir, "--match", "nothing_*"); code == 0 {
t.Error("expected a non-zero exit")
}
})
}
func TestReleaseSignFailsOnUnwritableOutputs(t *testing.T) {
t.Run("keygen reports a public key that cannot be written", func(t *testing.T) {
dir := t.TempDir()
code, _ := runReleaseSign("keygen", filepath.Join(dir, "release.key"), "--pub-out", filepath.Join(dir, "missing", "release.pub"))
if code == 0 {
t.Fatal("keygen exited 0 although the public key could not be written")
}
})
t.Run("keygen reports a trailing flag with no value without panicking", func(t *testing.T) {
code, _ := runReleaseSign("keygen", filepath.Join(t.TempDir(), "release.key"), "--pub-out")
if code == 0 {
t.Fatal("keygen exited 0 for a flag with no value")
}
})
t.Run("sign reports a signature that cannot be written", func(t *testing.T) {
dir := t.TempDir()
pair := newSigningKeyPair(t, dir)
file := writeChecksums(t, dir)
if err := os.Mkdir(file+".sig", 0o700); err != nil {
t.Fatal(err)
}
if code, _ := runReleaseSign("sign", file, "--key", pair.privatePath, "--pub", pair.publicPath); code == 0 {
t.Fatal("sign exited 0 although the signature could not be written")
}
})
t.Run("checksums reports a checksums file that cannot be written", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.bin"), []byte("a"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "checksums.txt"), 0o700); err != nil {
t.Fatal(err)
}
if code, _ := runReleaseSign("checksums", dir, "--match", "*.bin"); code == 0 {
t.Fatal("checksums exited 0 although checksums.txt could not be written")
}
})
}
+183
View File
@@ -0,0 +1,183 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"crypto/ed25519"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
const giteaReleasesPath = "/api/v1/repos/fsylvain/RenewCMS/releases"
func newGiteaFeedServer(t *testing.T, status int, body string) (*httptest.Server, *[]string) {
t.Helper()
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
return server, &paths
}
func TestGiteaReleaseFeed(t *testing.T) {
list := `[
{"tag_name":"v2.4.0-beta","html_url":"https://git.example/r/v2.4.0-beta","body":"- Four","draft":false,"prerelease":true,"published_at":"2026-08-01T10:00:00Z"},
{"tag_name":"v9.0.0","html_url":"https://git.example/r/v9.0.0","body":"- Draft","draft":true,"prerelease":false,"published_at":"2026-09-30T10:00:00Z"},
{"tag_name":"v2.5.0-beta","html_url":"https://git.example/r/v2.5.0-beta","body":"## New\n- **Media** library\n- Fixed [caching](http://x) bug","draft":false,"prerelease":true,"published_at":"2026-09-24T08:30:00Z"},
{"tag_name":"v2.3.0-beta","html_url":"https://git.example/r/v2.3.0-beta","body":"","draft":false,"prerelease":true,"published_at":"2026-07-01T10:00:00Z"}
]`
t.Run("requests the release list and returns the newest published release", func(t *testing.T) {
server, paths := newGiteaFeedServer(t, http.StatusOK, list)
release, err := infraUpdate.NewGiteaReleaseFeed(server.URL + "/").Latest()
if err != nil {
t.Fatalf("Latest: %v", err)
}
if !reflect.DeepEqual(*paths, []string{giteaReleasesPath}) {
t.Errorf("paths = %v", *paths)
}
if release.Tag != "v2.5.0-beta" || release.URL != "https://git.example/r/v2.5.0-beta" {
t.Errorf("release = %+v", release)
}
if want := []string{"Media library", "Fixed caching bug"}; !reflect.DeepEqual(release.Notes.Items, want) {
t.Errorf("items = %v", release.Notes.Items)
}
if want := time.Date(2026, 9, 24, 8, 30, 0, 0, time.UTC); !release.PublishedAt.Equal(want) || !release.Notes.ReleasedAt.Equal(want) {
t.Errorf("published = %v, notes released = %v", release.PublishedAt, release.Notes.ReleasedAt)
}
})
t.Run("a final release beats a beta of the same number", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, `[{"tag_name":"v2.6.0-beta","published_at":"2026-09-01T00:00:00Z"},{"tag_name":"v2.6.0","published_at":"2026-09-02T00:00:00Z"}]`)
release, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest()
if err != nil || release.Tag != "v2.6.0" {
t.Errorf("release = %+v, err = %v", release, err)
}
})
t.Run("only drafts is no release", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, `[{"tag_name":"v9.0.0","draft":true}]`)
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrNoRelease) {
t.Errorf("err = %v", err)
}
})
t.Run("a 500 is a feed error", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusInternalServerError, "boom")
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
})
t.Run("invalid json is a feed error", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, "not json")
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
})
t.Run("an unreachable server is a feed error", func(t *testing.T) {
server, _ := newGiteaFeedServer(t, http.StatusOK, "[]")
url := server.URL
server.Close()
if _, err := infraUpdate.NewGiteaReleaseFeed(url).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
})
}
func TestJSONStatusStore(t *testing.T) {
t.Run("The stored result survives a restart", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "data", "update-status.json")
saved := newStoredUpdateResult()
if err := infraUpdate.NewJSONStatusStore(path).Save(saved); err != nil {
t.Fatalf("Save: %v", err)
}
loaded, found := infraUpdate.NewJSONStatusStore(path).Load()
if !found {
t.Fatal("expected a stored result")
}
if loaded.Latest != saved.Latest || loaded.ReleaseURL != saved.ReleaseURL || !loaded.CheckedAt.Equal(saved.CheckedAt) {
t.Errorf("loaded = %+v", loaded)
}
if !reflect.DeepEqual(loaded.Notes.Items, saved.Notes.Items) {
t.Errorf("items = %v", loaded.Notes.Items)
}
})
t.Run("a second save replaces the first", func(t *testing.T) {
store := infraUpdate.NewJSONStatusStore(filepath.Join(t.TempDir(), "update-status.json"))
first := newStoredUpdateResult()
second := newStoredUpdateResult()
second.Latest = "v2.4.0-beta"
_ = store.Save(first)
_ = store.Save(second)
if loaded, _ := store.Load(); loaded.Latest != "v2.4.0-beta" {
t.Errorf("latest = %q", loaded.Latest)
}
})
t.Run("a missing file reads as no result", func(t *testing.T) {
if _, found := infraUpdate.NewJSONStatusStore(filepath.Join(t.TempDir(), "absent.json")).Load(); found {
t.Error("a missing file must read as no result")
}
})
t.Run("a corrupt file reads as no result", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "update-status.json")
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
if _, found := infraUpdate.NewJSONStatusStore(path).Load(); found {
t.Error("a corrupt file must read as no result")
}
})
t.Run("an unwritable location is an error", func(t *testing.T) {
blocker := filepath.Join(t.TempDir(), "file")
if err := os.WriteFile(blocker, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := infraUpdate.NewJSONStatusStore(filepath.Join(blocker, "update-status.json")).Save(newStoredUpdateResult()); err == nil {
t.Error("expected an error")
}
})
}
func TestCommittedReleasePublicKeyHasTheRightShape(t *testing.T) {
content, err := os.ReadFile(filepath.Join("..", "internal", "infrastructure", "update", "release.pub"))
if err != nil {
t.Fatal(err)
}
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(content)))
if err != nil || len(decoded) != ed25519.PublicKeySize {
t.Fatalf("release.pub must be base64 of %d bytes: %v", ed25519.PublicKeySize, err)
}
if _, err := infraUpdate.EmbeddedPublicKey(); err != nil {
t.Errorf("embedded key: %v", err)
}
}
func TestGiteaReleaseFeedRejectsAnOversizedBody(t *testing.T) {
huge := `[{"tag_name":"v2.5.0","html_url":"u","body":"` + strings.Repeat("a", 5<<20) + `","draft":false,"published_at":"2026-09-24T08:30:00Z"}]`
server, _ := newGiteaFeedServer(t, http.StatusOK, huge)
if _, err := infraUpdate.NewGiteaReleaseFeed(server.URL).Latest(); !errors.Is(err, update.ErrFeedUnavailable) {
t.Fatalf("Latest error = %v, want ErrFeedUnavailable", err)
}
}
+317
View File
@@ -0,0 +1,317 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/domain/user"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strconv"
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
)
const (
updateOwnerId = 1
updateAdminId = 2
updateUnverifiedId = 3
updateSecondOwner = 4
updateRunning = "v2.2.0-beta"
)
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "latest", "notes", "progress", "release_url", "started_at", "state"}
type updateHarness struct {
router http.Handler
token *jwtauth.JWTAuth
feed *fakeReleaseFeed
store *fakeUpdateStore
}
func newUpdateHarness(t *testing.T, running string) *updateHarness {
t.Helper()
validator.InitValidator()
users := newMockUserRepo()
users.users[updateOwnerId] = user.User{ID: updateOwnerId, IsVerified: true, Role: user.RoleOwner, TokenVersion: 2}
users.users[updateAdminId] = user.User{ID: updateAdminId, IsVerified: true, Role: user.RoleAdmin}
users.users[updateUnverifiedId] = user.User{ID: updateUnverifiedId, IsVerified: false, Role: user.RoleAdmin}
users.users[updateSecondOwner] = user.User{ID: updateSecondOwner, IsVerified: true, Role: user.RoleOwner}
feed := &fakeReleaseFeed{release: update.Release{
Tag: "v2.3.0-beta",
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
PublishedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
Notes: update.ReleaseNotes{Version: "v2.3.0-beta", Items: []string{"Media library"}},
}}
store := &fakeUpdateStore{}
token := jwtauth.New("HS256", []byte("test-secret"), nil)
now := func() time.Time { return updateTestCheckedAt }
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
}
func (h *updateHarness) send(t *testing.T, method, path string, userId uint32, tokenVersion uint32) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
if userId != 0 {
_, tokenString, err := h.token.Encode(map[string]any{"user_id": userId, "token_version": tokenVersion})
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
}
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec
}
func (h *updateHarness) sendAs(t *testing.T, method, path string, userId uint32) *httptest.ResponseRecorder {
tokenVersion := uint32(0)
if userId == updateOwnerId {
tokenVersion = 2
}
return h.send(t, method, path, userId, tokenVersion)
}
func decodeUpdateBody(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not json: %v: %s", err, rec.Body.String())
}
return body
}
func assertUpdateKeys(t *testing.T, body map[string]any) {
t.Helper()
keys := make([]string, 0, len(body))
for key := range body {
keys = append(keys, key)
}
sort.Strings(keys)
if len(keys) != len(updateStatusKeys) {
t.Fatalf("keys = %v, want %v", keys, updateStatusKeys)
}
for i := range keys {
if keys[i] != updateStatusKeys[i] {
t.Fatalf("keys = %v, want %v", keys, updateStatusKeys)
}
}
}
func TestUpdateStatusHTTP(t *testing.T) {
t.Run("GET returns the idle shape, never applicable", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
h.store.result = newStoredUpdateResult()
h.store.found = true
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if body["current"] != updateRunning || body["latest"] != "v2.3.0-beta" || body["available"] != true || body["can_apply"] != false || body["state"] != "idle" {
t.Errorf("body = %v", body)
}
if body["release_url"] != h.store.result.ReleaseURL || body["checked_at"] != "2026-09-30T09:41:00Z" {
t.Errorf("release_url = %v, checked_at = %v", body["release_url"], body["checked_at"])
}
if body["progress"] != nil || body["error"] != nil || body["started_at"] != nil {
t.Errorf("progress, error and started_at must be null: %v", body)
}
notes := body["notes"].(map[string]any)
if notes["version"] != "v2.3.0-beta" || len(notes["items"].([]any)) != 1 || notes["released_at"] != "2026-09-24T08:00:00Z" {
t.Errorf("notes = %v", notes)
}
if h.feed.calls != 0 {
t.Errorf("feed calls = %d", h.feed.calls)
}
})
t.Run("GET before any check", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if rec.Code != http.StatusOK || body["available"] != false || body["latest"] != "" || body["state"] != "idle" {
t.Errorf("status = %d body = %v", rec.Code, body)
}
for _, key := range []string{"release_url", "notes", "checked_at", "progress", "error", "started_at"} {
if body[key] != nil {
t.Errorf("%s = %v, want null", key, body[key])
}
}
})
for name, userId := range map[string]uint32{"owner": updateOwnerId, "admin": updateAdminId} {
t.Run("GET is open to any verified user: "+name, func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", userId)
if rec.Code != http.StatusOK {
t.Errorf("status = %d", rec.Code)
}
})
}
t.Run("GET without a cookie", func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", 0, 0)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d", rec.Code)
}
})
t.Run("GET with a stale token", func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).send(t, http.MethodGet, "/update", updateOwnerId, 1)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != apperr.UnauthorizedErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
})
t.Run("GET by an unverified account", func(t *testing.T) {
rec := newUpdateHarness(t, updateRunning).sendAs(t, http.MethodGet, "/update", updateUnverifiedId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.VerificationRequiredErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
})
}
func TestUpdateCheckHTTP(t *testing.T) {
t.Run("The owner checks and the result is stored", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if body["available"] != true || body["latest"] != "v2.3.0-beta" || body["checked_at"] != "2026-09-30T09:41:00Z" {
t.Errorf("body = %v", body)
}
if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) {
t.Errorf("stored = %+v", h.store.result)
}
later := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
if later.Body.String() != rec.Body.String() {
t.Errorf("later GET = %s, check = %s", later.Body.String(), rec.Body.String())
}
if h.feed.calls != 1 {
t.Errorf("feed calls = %d", h.feed.calls)
}
})
t.Run("A check with nothing newer", func(t *testing.T) {
h := newUpdateHarness(t, "v2.3.0-beta")
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false {
t.Errorf("status = %d body = %s", rec.Code, rec.Body.String())
}
})
t.Run("A dev build never reports an update", func(t *testing.T) {
h := newUpdateHarness(t, "dev")
h.feed.release.Tag = "v9.9.9"
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusOK || decodeUpdateBody(t, rec)["available"] != false {
t.Errorf("status = %d body = %s", rec.Code, rec.Body.String())
}
})
t.Run("An admin cannot check", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
rec := h.sendAs(t, http.MethodPost, "/update/check", updateAdminId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if h.feed.calls != 0 {
t.Errorf("feed calls = %d", h.feed.calls)
}
})
t.Run("A check without a cookie is unauthorized", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
if rec := h.send(t, http.MethodPost, "/update/check", 0, 0); rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d", rec.Code)
}
})
t.Run("Feed failure keeps the last result", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
h.store.result = newStoredUpdateResult()
h.store.found = true
h.feed.err = update.ErrFeedUnavailable
rec := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d", rec.Code)
}
if h.store.result.Latest != "v2.3.0-beta" || !h.store.result.CheckedAt.Equal(updateTestCheckedAt) || h.store.saves != 0 {
t.Errorf("store changed: %+v", h.store.result)
}
if body := rec.Body.String(); body != "Internal server error\n" {
t.Errorf("body leaks detail: %q", body)
}
})
t.Run("Check is rate limited per user", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
loginLimited := middleware.NewRateLimiter(5, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
var codes []int
var last *httptest.ResponseRecorder
for i := 0; i < 6; i++ {
last = h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId)
codes = append(codes, last.Code)
}
for i, code := range codes[:5] {
if code != http.StatusOK {
t.Errorf("check %d status = %d", i+1, code)
}
}
if codes[5] != http.StatusTooManyRequests || last.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" {
t.Fatalf("6th status = %d body = %q", codes[5], last.Body.String())
}
if seconds, err := strconv.Atoi(last.Header().Get("Retry-After")); err != nil || seconds < 1 {
t.Errorf("Retry-After = %q", last.Header().Get("Retry-After"))
}
if h.feed.calls != 5 {
t.Errorf("feed calls = %d", h.feed.calls)
}
if other := h.sendAs(t, http.MethodPost, "/update/check", updateSecondOwner); other.Code != http.StatusOK {
t.Errorf("another owner status = %d", other.Code)
}
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
loginLimited.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/login", nil))
if rec.Code != http.StatusOK {
t.Errorf("auth limiter request %d status = %d", i+1, rec.Code)
}
}
})
t.Run("download and restart are not mounted", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
for _, path := range []string{"/update/download", "/update/restart"} {
if rec := h.sendAs(t, http.MethodPost, path, updateOwnerId); rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed {
t.Errorf("%s status = %d", path, rec.Code)
}
}
})
}
+241
View File
@@ -0,0 +1,241 @@
package test
import (
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"reflect"
"testing"
"time"
)
type fakeReleaseFeed struct {
release update.Release
err error
calls int
}
var _ update.ReleaseFeed = &fakeReleaseFeed{}
func (f *fakeReleaseFeed) Latest() (update.Release, error) {
f.calls++
if f.err != nil {
return update.Release{}, f.err
}
return f.release, nil
}
type fakeUpdateStore struct {
result update.CheckResult
found bool
saveErr error
saves int
}
var _ update.StatusStore = &fakeUpdateStore{}
func (f *fakeUpdateStore) Load() (update.CheckResult, bool) {
return f.result, f.found
}
func (f *fakeUpdateStore) Save(result update.CheckResult) error {
f.saves++
if f.saveErr != nil {
return f.saveErr
}
f.result = result
f.found = true
return nil
}
var updateTestCheckedAt = time.Date(2026, 9, 30, 9, 41, 0, 0, time.UTC)
func newStoredUpdateResult() update.CheckResult {
return update.CheckResult{
Latest: "v2.3.0-beta",
ReleaseURL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
Notes: update.ReleaseNotes{
Version: "v2.3.0-beta",
ReleasedAt: time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC),
Items: []string{"Media library"},
},
CheckedAt: updateTestCheckedAt,
}
}
func TestVersionComparison(t *testing.T) {
cases := []struct {
current, latest string
available bool
}{
{"v2.2.0-beta", "v2.3.0-beta", true},
{"v2.5.0-beta", "v2.5.0", true},
{"v2.5.0", "v2.5.0-beta", false},
{"v2.9.0", "v2.10.0", true},
{"v2.5.0-beta", "v2.5.0-beta", false},
{"v2.5.0", "v2.4.9", false},
{"dev", "v9.9.9", false},
{"v2.5.0-beta.2", "v2.5.0-beta.10", true},
{"v2.5.0-alpha", "v2.5.0-beta", true},
{"2.0.0-beta", "v2.1.0-beta", true},
{"v2.5", "v2.5.0", false},
{"v2.2.0", "garbage", false},
{"v1.0.0-1", "v1.0.0-alpha", true},
{"v1.0.0-alpha", "v1.0.0-1", false},
{"v1.0.0-beta", "v1.0.0-beta.1", true},
{"v1.0.0-beta.1", "v1.0.0-beta", false},
{"v1.0.0-beta.2", "v1.0.0-beta.1", false},
{"v1.0.0-beta", "v1.0.0-alpha", false},
{"v1.0.0", "v1.0.0", false},
{"v1.1.0", "v1.0.9", false},
}
for _, c := range cases {
t.Run(c.current+" to "+c.latest, func(t *testing.T) {
if got := update.IsNewer(c.current, c.latest); got != c.available {
t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.available)
}
})
}
}
func TestParseVersionRejectsGarbage(t *testing.T) {
for _, raw := range []string{"", "dev", "v", "v1.x.0", "v1.2.3-", "v-1.0.0"} {
if _, err := update.ParseVersion(raw); !errors.Is(err, update.ErrInvalidVersion) {
t.Errorf("ParseVersion(%q) error = %v, want ErrInvalidVersion", raw, err)
}
}
}
func TestNewestReleaseIsChosenRegardlessOfOrder(t *testing.T) {
releases := []update.Release{{Tag: "v2.4.0-beta"}, {Tag: "v2.5.0-beta"}, {Tag: "v2.3.0-beta"}}
newest, err := update.Newest(releases)
if err != nil || newest.Tag != "v2.5.0-beta" {
t.Fatalf("Newest = %q, %v, want v2.5.0-beta", newest.Tag, err)
}
}
func TestNewestReleaseOfNothingIsAnError(t *testing.T) {
if _, err := update.Newest(nil); !errors.Is(err, update.ErrNoRelease) {
t.Errorf("error = %v, want ErrNoRelease", err)
}
if _, err := update.Newest([]update.Release{{Tag: "nightly"}}); !errors.Is(err, update.ErrNoRelease) {
t.Errorf("error = %v, want ErrNoRelease for an unparsable tag", err)
}
}
func TestReleaseNotesBecomeAPlainTextList(t *testing.T) {
published := time.Date(2026, 9, 24, 8, 0, 0, 0, time.UTC)
body := "## What's new\n- **Media** library\n- Fixed [caching](http://x) bug\n\n* Faster start"
notes := update.BuildNotes("v2.3.0-beta", published, body)
want := []string{"Media library", "Fixed caching bug", "Faster start"}
if !reflect.DeepEqual(notes.Items, want) {
t.Errorf("items = %v, want %v", notes.Items, want)
}
if !notes.ReleasedAt.Equal(published) || notes.Version != "v2.3.0-beta" {
t.Errorf("notes = %+v", notes)
}
}
func TestReleaseNotesWithoutBulletsHaveNoItems(t *testing.T) {
notes := update.BuildNotes("v1.0.0", time.Time{}, "Just an intro.\n\n**Group**\n")
if notes.Items == nil || len(notes.Items) != 0 {
t.Errorf("items = %#v, want an empty non-nil list", notes.Items)
}
}
func TestReleaseNotesStripInlineMarkdown(t *testing.T) {
notes := update.BuildNotes("v1.0.0", time.Time{}, "- Run `task build` and *enjoy* __it__")
if want := []string{"Run task build and enjoy it"}; !reflect.DeepEqual(notes.Items, want) {
t.Errorf("items = %v, want %v", notes.Items, want)
}
}
func TestGetUpdateStatusUseCase(t *testing.T) {
t.Run("never checked", func(t *testing.T) {
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, "v2.2.0-beta").GetStatus()
if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available {
t.Errorf("status = %+v", status)
}
if status.ReleaseURL != nil || status.Notes != nil || status.CheckedAt != nil || status.Progress != nil || status.Error != nil || status.StartedAt != nil {
t.Errorf("never-checked values must be nil: %+v", status)
}
if status.State != update.StateIdle || status.CanApply {
t.Errorf("state = %q, can_apply = %v", status.State, status.CanApply)
}
})
t.Run("stored result", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
status := useCases.NewGetUpdateStatusUseCase(store, "v2.2.0-beta").GetStatus()
if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle {
t.Errorf("status = %+v", status)
}
if status.ReleaseURL == nil || *status.ReleaseURL != store.result.ReleaseURL {
t.Errorf("release_url = %v", status.ReleaseURL)
}
if status.CheckedAt == nil || !status.CheckedAt.Equal(updateTestCheckedAt) {
t.Errorf("checked_at = %v", status.CheckedAt)
}
})
t.Run("dev build never reports an update", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
if useCases.NewGetUpdateStatusUseCase(store, "dev").GetStatus().Available {
t.Error("a dev build must not report an update")
}
})
}
func TestCheckForUpdateUseCase(t *testing.T) {
newRelease := update.Release{Tag: "v2.3.0-beta", URL: "https://git.example/r/v2.3.0-beta", PublishedAt: updateTestCheckedAt}
now := func() time.Time { return updateTestCheckedAt }
t.Run("stores the result and reports it", func(t *testing.T) {
store := &fakeUpdateStore{}
feed := &fakeReleaseFeed{release: newRelease}
status, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
if err != nil || !status.Available || status.Latest != "v2.3.0-beta" {
t.Fatalf("status = %+v, err = %v", status, err)
}
if store.result.Latest != "v2.3.0-beta" || store.result.ReleaseURL != newRelease.URL || !store.result.CheckedAt.Equal(updateTestCheckedAt) {
t.Errorf("stored = %+v", store.result)
}
})
t.Run("nothing newer", func(t *testing.T) {
feed := &fakeReleaseFeed{release: update.Release{Tag: "v2.2.0-beta"}}
status, err := useCases.NewCheckForUpdateUseCase(feed, &fakeUpdateStore{}, "v2.2.0-beta", now).Check()
if err != nil || status.Available {
t.Errorf("status = %+v, err = %v", status, err)
}
})
t.Run("feed failure keeps the previous result", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
feed := &fakeReleaseFeed{err: update.ErrFeedUnavailable}
_, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check()
if !errors.Is(err, update.ErrFeedUnavailable) {
t.Errorf("err = %v", err)
}
if store.saves != 0 || store.result.Latest != "v2.3.0-beta" {
t.Errorf("store changed: %+v saves %d", store.result, store.saves)
}
})
t.Run("store failure is returned", func(t *testing.T) {
store := &fakeUpdateStore{saveErr: errRepositoryFailure}
feed := &fakeReleaseFeed{release: newRelease}
if _, err := useCases.NewCheckForUpdateUseCase(feed, store, "v2.2.0-beta", now).Check(); !errors.Is(err, errRepositoryFailure) {
t.Errorf("err = %v", err)
}
})
}