feat: update button (download, verify, stage)

This commit is contained in:
fsylvain committed 2026-10-01 18:13:45 +02:00
1 parent 834493a4f5
commit 70460e4170
41 files changed
+5345 -97

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
package test
import (
infraUpdate "RenewCMS/internal/infrastructure/update"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
func buildServerBinary(t *testing.T, ldflags string) string {
t.Helper()
if _, err := exec.LookPath("go"); err != nil {
t.Skip("the go tool is not on PATH")
}
name := "server"
if runtime.GOOS == "windows" {
name += ".exe"
}
output := filepath.Join(t.TempDir(), name)
arguments := []string{"build", "-tags", "nofrontend"}
if ldflags != "" {
arguments = append(arguments, "-ldflags", ldflags)
}
arguments = append(arguments, "-o", output, "RenewCMS/cmd/server")
build := exec.Command("go", arguments...)
build.Env = append(os.Environ(), "CGO_ENABLED=0")
if combined, err := build.CombinedOutput(); err != nil {
t.Fatalf("build failed: %v\n%s", err, combined)
}
return output
}
func emptyEnvironment() []string {
if runtime.GOOS == "windows" {
return []string{"SystemRoot=" + os.Getenv("SystemRoot")}
}
return []string{}
}
func TestServerVersionFlag(t *testing.T) {
t.Run("it prints the build version and exits 0 without reading .env or the database", func(t *testing.T) {
binary := buildServerBinary(t, "-X main.version=v9.9.9")
workDir := t.TempDir()
command := exec.Command(binary, "--version")
command.Dir = workDir
command.Env = emptyEnvironment()
output, err := command.Output()
if err != nil || strings.TrimSpace(string(output)) != "v9.9.9" {
t.Fatalf("output = %q, err = %v", output, err)
}
if entries, _ := os.ReadDir(workDir); len(entries) != 0 {
t.Errorf("the working directory must stay empty, got %d entries", len(entries))
}
})
t.Run("with no ldflags version it prints dev", func(t *testing.T) {
binary := buildServerBinary(t, "")
command := exec.Command(binary, "--version")
command.Dir = t.TempDir()
command.Env = emptyEnvironment()
output, err := command.Output()
if err != nil || strings.TrimSpace(string(output)) != "dev" {
t.Errorf("output = %q, err = %v", output, err)
}
})
t.Run("the staged binary runner returns what the binary prints", func(t *testing.T) {
binary := buildServerBinary(t, "-X main.version=v9.9.9")
version, err := infraUpdate.NewExecStagedBinaryRunner().Version(binary)
if err != nil || version != "v9.9.9" {
t.Errorf("version = %q, err = %v", version, err)
}
if _, err := infraUpdate.NewExecStagedBinaryRunner().Version(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("a binary that cannot be run must be an error")
}
})
}
+344
View File
@@ -0,0 +1,344 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)
var errAssetDisk = errors.New("write failed: no space left on device")
type failingWriteCloser struct {
file *os.File
allowed int
}
func (f *failingWriteCloser) Write(chunk []byte) (int, error) {
if f.allowed <= 0 {
return 0, errAssetDisk
}
if len(chunk) > f.allowed {
written, _ := f.file.Write(chunk[:f.allowed])
f.allowed = 0
return written, errAssetDisk
}
f.allowed -= len(chunk)
return f.file.Write(chunk)
}
func (f *failingWriteCloser) Close() error {
return f.file.Close()
}
func assetOf(server *httptest.Server, name string, size int64) update.Asset {
return update.Asset{Name: name, URL: server.URL + "/" + name, Size: size}
}
func fetchTarget(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "staged", "asset")
}
func noProgress(int64, int64) {}
func TestHTTPAssetFetcherDownloads(t *testing.T) {
payload := bytes.Repeat([]byte("renewcms"), 512)
t.Run("writes the body and reports bytes done and total", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(payload) }))
defer server.Close()
fetcher := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme())
target := fetchTarget(t)
var lastDone, lastTotal int64
err := fetcher.Fetch(assetOf(server, "binary", int64(len(payload))), target, func(done, total int64) { lastDone, lastTotal = done, total })
written, readErr := os.ReadFile(target)
if err != nil || readErr != nil || !bytes.Equal(written, payload) {
t.Fatalf("err = %v, read = %v", err, readErr)
}
if lastDone != int64(len(payload)) || lastTotal != int64(len(payload)) {
t.Errorf("progress = %d of %d", lastDone, lastTotal)
}
})
t.Run("a truncated body is an error and leaves no file", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "4096")
_, _ = w.Write(payload[:2048])
w.(http.Flusher).Flush()
panic(http.ErrAbortHandler)
}))
defer server.Close()
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 4096), target, noProgress)
if err == nil {
t.Error("a truncated download must fail")
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Error("the partial file must be removed")
}
})
t.Run("a file over its size cap is refused", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Transfer-Encoding", "chunked")
_, _ = w.Write(payload)
}))
defer server.Close()
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), target, noProgress)
if err == nil {
t.Error("the size cap must stop the download")
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Error("the oversized file must be removed")
}
})
t.Run("the production caps are 64 KiB for checksums and 1 KiB for the signature", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(bytes.Repeat([]byte("a"), 2048))
}))
defer server.Close()
fetcher := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme())
if err := fetcher.Fetch(assetOf(server, "checksums.txt.sig", 2048), fetchTarget(t), noProgress); err == nil {
t.Error("a 2 KiB signature must exceed the 1 KiB cap")
}
if err := fetcher.Fetch(assetOf(server, "checksums.txt", 2048), fetchTarget(t), noProgress); err != nil {
t.Errorf("2 KiB of checksums is within the cap: %v", err)
}
})
t.Run("a stalled server hits the overall timeout", func(t *testing.T) {
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "4096")
_, _ = w.Write(payload[:10])
w.(http.Flusher).Flush()
select {
case <-release:
case <-r.Context().Done():
}
}))
defer server.Close()
defer close(release)
started := time.Now()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithTimeout(150*time.Millisecond)).Fetch(assetOf(server, "binary", 4096), fetchTarget(t), noProgress)
if err == nil || time.Since(started) > 3*time.Second {
t.Errorf("err = %v after %v", err, time.Since(started))
}
})
t.Run("a 404 or 500 is an error", func(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusInternalServerError} {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) }))
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 1), target, noProgress)
server.Close()
if err == nil {
t.Errorf("status %d must fail", status)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Errorf("status %d left a file", status)
}
}
})
t.Run("the disk filling during the write is an error and leaves no file", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(payload) }))
defer server.Close()
opener := func(path string) (io.WriteCloser, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
file, err := os.Create(path)
return &failingWriteCloser{file: file, allowed: 100}, err
}
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithFileOpener(opener)).Fetch(assetOf(server, "binary", int64(len(payload))), target, noProgress)
if !errors.Is(err, errAssetDisk) {
t.Errorf("err = %v", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Error("the partial file must be removed")
}
})
}
func TestHTTPAssetFetcherProductionPolicy(t *testing.T) {
payload := []byte("signed release asset")
t.Run("an http URL is refused", func(t *testing.T) {
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = w.Write(payload)
}))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL).Fetch(assetOf(server, "binary", int64(len(payload))), fetchTarget(t), noProgress)
if err == nil || requests.Load() != 0 {
t.Errorf("err = %v, requests = %d", err, requests.Load())
}
})
t.Run("an https URL on another host is refused", func(t *testing.T) {
var requests atomic.Int32
other := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) }))
defer other.Close()
allowed := httptest.NewTLSServer(http.NotFoundHandler())
defer allowed.Close()
err := infraUpdate.NewHTTPAssetFetcher(allowed.URL, infraUpdate.WithTransport(other.Client().Transport)).Fetch(assetOf(other, "binary", 1), fetchTarget(t), noProgress)
if err == nil || requests.Load() != 0 {
t.Errorf("err = %v, requests = %d", err, requests.Load())
}
})
t.Run("a redirect to another host is refused", func(t *testing.T) {
var reached atomic.Int32
second := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached.Add(1)
_, _ = w.Write(payload)
}))
defer second.Close()
first := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, second.URL+"/binary", http.StatusFound)
}))
defer first.Close()
err := infraUpdate.NewHTTPAssetFetcher(first.URL, infraUpdate.WithTransport(first.Client().Transport)).Fetch(assetOf(first, "binary", 1), fetchTarget(t), noProgress)
if err == nil || reached.Load() != 0 {
t.Errorf("err = %v, second server reached %d times", err, reached.Load())
}
})
t.Run("a redirect to http is refused", func(t *testing.T) {
var reached atomic.Int32
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
reached.Add(1)
_, _ = w.Write(payload)
}))
defer plain.Close()
secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, strings.Replace(r.URL.Path, "/", plain.URL+"/", 1), http.StatusFound)
}))
defer secure.Close()
err := infraUpdate.NewHTTPAssetFetcher(secure.URL, infraUpdate.WithTransport(secure.Client().Transport)).Fetch(assetOf(secure, "binary", 1), fetchTarget(t), noProgress)
if err == nil || reached.Load() != 0 {
t.Errorf("err = %v, plain server reached %d times", err, reached.Load())
}
})
t.Run("an https URL on the allowed host is accepted, also after a same host redirect", func(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/binary" {
http.Redirect(w, r, "/final", http.StatusFound)
return
}
_, _ = w.Write(payload)
}))
defer server.Close()
target := fetchTarget(t)
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithTransport(server.Client().Transport)).Fetch(assetOf(server, "binary", int64(len(payload))), target, noProgress)
written, _ := os.ReadFile(target)
if err != nil || !bytes.Equal(written, payload) {
t.Errorf("err = %v, body = %q", err, written)
}
})
}
func TestHTTPAssetFetcherBoundaries(t *testing.T) {
exact := bytes.Repeat([]byte("x"), 100)
t.Run("a body exactly at the cap is accepted with a content length", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(exact) }))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress)
if err != nil {
t.Errorf("err = %v", err)
}
})
t.Run("a chunked body exactly at the cap is accepted", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Transfer-Encoding", "chunked")
_, _ = w.Write(exact)
}))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme(), infraUpdate.WithSizeCap(100)).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress)
if err != nil {
t.Errorf("err = %v", err)
}
})
t.Run("checksums over 64 KiB are refused without a custom cap", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(bytes.Repeat([]byte("a"), 64<<10+1))
}))
defer server.Close()
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "checksums.txt", 0), fetchTarget(t), noProgress)
if err == nil {
t.Error("65 KiB of checksums must exceed the cap")
}
})
t.Run("redirects are followed up to four hops and the fifth is refused", func(t *testing.T) {
for _, hops := range []int{4, 5} {
var served atomic.Int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if int(served.Add(1)) <= hops {
http.Redirect(w, r, server.URL+"/next", http.StatusFound)
return
}
_, _ = w.Write(exact)
}))
err := infraUpdate.NewHTTPAssetFetcher(server.URL, infraUpdate.WithInsecureScheme()).Fetch(assetOf(server, "binary", 100), fetchTarget(t), noProgress)
server.Close()
if hops == 4 && err != nil {
t.Errorf("%d redirects: err = %v", hops, err)
}
if hops == 5 && err == nil {
t.Errorf("%d redirects must be refused", hops)
}
}
})
}
+704
View File
@@ -0,0 +1,704 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/middleware"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"context"
"crypto/ed25519"
"crypto/rand"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
)
const downloadPath = "/update/download"
func newDownloadHTTP(t *testing.T, rig *downloadRig) *updateHarness {
t.Helper()
validator.InitValidator()
token := jwtauth.New("HS256", []byte("test-secret"), nil)
users := newUpdateUserRepo()
feed := &fakeReleaseFeed{}
now := func() time.Time { return updateTestCheckedAt }
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: rig.status,
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, rig.store, updateRunning, now),
StartUpdateDownloadUseCase: rig.start,
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: rig.store}
}
func waitForHTTPState(t *testing.T, h *updateHarness, states ...string) map[string]any {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
var body map[string]any
for time.Now().Before(deadline) {
body = decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
for _, state := range states {
if body["state"] == state {
return body
}
}
time.Sleep(2 * time.Millisecond)
}
t.Fatalf("state never reached %v, last body %v", states, body)
return nil
}
type pausedDownload struct {
reached chan struct{}
release chan struct{}
}
func pauseBinaryAt(rig *downloadRig, done int64, total int64) *pausedDownload {
pause := &pausedDownload{reached: make(chan struct{}), release: make(chan struct{})}
var once sync.Once
rig.fetcher.during = func(name string, report update.ProgressFunc) {
if name != rig.assetName {
return
}
once.Do(func() {
report(done, total)
close(pause.reached)
})
<-pause.release
}
return pause
}
func newBoardSizedRig(t *testing.T) *downloadRig {
t.Helper()
return newDownloadRigFor(t, update.Platform{OS: "linux", Arch: "amd64"}, make([]byte, boardTotalBytes))
}
func TestStartUpdateDownloadHTTP(t *testing.T) {
t.Run("Start the update, happy path", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
gate := make(chan struct{})
rig.fetcher.during = func(name string, _ update.ProgressFunc) {
if name == rig.assetName {
<-gate
}
}
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
body := decodeUpdateBody(t, rec)
assertUpdateKeys(t, body)
if body["state"] != "downloading" || body["started_at"] == nil || body["error"] != nil || body["can_apply"] != true {
t.Errorf("body = %v", body)
}
during := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
progress, _ := during["progress"].(map[string]any)
if during["state"] != "downloading" || progress == nil || progress["total_bytes"] != float64(len(rig.fetcher.files[rig.assetName])) {
t.Errorf("during = %v", during)
}
close(gate)
ready := waitForHTTPState(t, h, "ready", "failed")
if ready["state"] != "ready" || ready["error"] != nil || ready["progress"] != nil || ready["latest"] != downloadTag {
t.Fatalf("ready = %v, log: %s", ready, rig.logger.text())
}
for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} {
if _, err := os.Stat(filepath.Join(rig.updatesDir, downloadTag, name)); err != nil {
t.Errorf("staged %s: %v", name, err)
}
}
if rig.backup.callCount() != 1 || rig.backup.seen[0] != update.StateVerifying {
t.Errorf("backup calls = %d, state seen = %v", rig.backup.callCount(), rig.backup.seen)
}
if _, err := os.Stat(filepath.Join(rig.dataDir, "renewcms.db")); err == nil {
t.Error("the database file must not be touched")
}
})
t.Run("Progress is reported in bytes", func(t *testing.T) {
rig := newBoardSizedRig(t)
h := newDownloadHTTP(t, rig)
pause := pauseBinaryAt(rig, boardDoneBytes, boardTotalBytes)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
<-pause.reached
body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateAdminId))
close(pause.release)
waitForHTTPState(t, h, "ready", "failed")
progress, _ := body["progress"].(map[string]any)
if body["state"] != "downloading" || progress == nil || progress["percent"] != float64(42) || progress["downloaded_bytes"] != float64(boardDoneBytes) || progress["total_bytes"] != float64(boardTotalBytes) {
t.Errorf("body = %v", body)
}
})
t.Run("Every owner sees the same job", func(t *testing.T) {
rig := newBoardSizedRig(t)
h := newDownloadHTTP(t, rig)
pause := pauseBinaryAt(rig, boardDoneBytes, boardTotalBytes)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
<-pause.reached
owner := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateSecondOwner))
admin := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateAdminId))
close(pause.release)
waitForHTTPState(t, h, "ready", "failed")
if owner["state"] != "downloading" || admin["state"] != "downloading" || owner["started_at"] != admin["started_at"] || owner["started_at"] == nil {
t.Errorf("owner = %v admin = %v", owner, admin)
}
ownerProgress, _ := owner["progress"].(map[string]any)
adminProgress, _ := admin["progress"].(map[string]any)
if ownerProgress == nil || adminProgress == nil || ownerProgress["percent"] != adminProgress["percent"] || ownerProgress["downloaded_bytes"] != adminProgress["downloaded_bytes"] {
t.Errorf("progress differs: %v vs %v", ownerProgress, adminProgress)
}
if owner["can_apply"] != true || admin["can_apply"] != false {
t.Errorf("can_apply owner = %v admin = %v", owner["can_apply"], admin["can_apply"])
}
})
t.Run("Only the owner", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateAdminId)
if rec.Code != http.StatusForbidden || rec.Body.String() != apperr.OwnerRequiredErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
})
t.Run("POST is guarded like the other system routes", func(t *testing.T) {
cases := []struct {
name string
userId uint32
tokenVersion uint32
status int
}{
{"no jwt cookie", 0, 0, http.StatusUnauthorized},
{"a stale token version", updateOwnerId, 1, http.StatusUnauthorized},
{"an unverified owner", updateUnverifiedOwner, 0, http.StatusForbidden},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rec := h.send(t, http.MethodPost, downloadPath, c.userId, c.tokenVersion)
if rec.Code != c.status {
t.Errorf("status = %d, want %d", rec.Code, c.status)
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start")
}
})
}
})
t.Run("Nothing to update", func(t *testing.T) {
t.Run("available false", func(t *testing.T) {
rig := newDownloadRig(t)
rig.store.result.Latest = updateRunning
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusBadRequest)
})
t.Run("no stored result", func(t *testing.T) {
rig := newDownloadRig(t)
rig.store.found = false
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusBadRequest)
})
})
t.Run("The server is not prepared", func(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
h := newDownloadHTTP(t, rig)
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId), http.StatusConflict)
if body := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId)); body["can_apply"] != false {
t.Errorf("can_apply = %v", body["can_apply"])
}
rig.store.result.Latest = updateRunning
assertNothingStarted(t, rig, h.sendAs(t, http.MethodPost, downloadPath, updateSecondOwner), http.StatusConflict)
})
t.Run("A second start while a job exists", func(t *testing.T) {
for _, state := range []string{update.StateDownloading, update.StateVerifying, update.StateReady} {
t.Run(state, func(t *testing.T) {
rig := newDownloadRig(t)
existing := update.Job{Tag: downloadTag, State: state, StartedAt: time.Date(2026, 9, 30, 9, 0, 0, 0, time.UTC)}
rig.jobs.put(existing)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusConflict {
t.Errorf("status = %d", rec.Code)
}
if job, _ := rig.jobs.Load(); job != existing || rig.fetcher.totalCalls() != 0 {
t.Errorf("the existing job was touched: %+v", job)
}
})
}
})
t.Run("Two starts at once run one job", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
gate := make(chan struct{})
rig.fetcher.during = func(name string, _ update.ProgressFunc) {
if name == rig.assetName {
<-gate
}
}
codes := make(chan int, 2)
var ready sync.WaitGroup
ready.Add(2)
for _, owner := range []uint32{updateOwnerId, updateSecondOwner} {
go func() {
ready.Done()
ready.Wait()
codes <- h.sendAs(t, http.MethodPost, downloadPath, owner).Code
}()
}
first, second := <-codes, <-codes
close(gate)
waitForHTTPState(t, h, "ready", "failed")
accepted, conflicts := 0, 0
for _, code := range []int{first, second} {
switch code {
case http.StatusAccepted:
accepted++
case http.StatusConflict:
conflicts++
}
}
if accepted != 1 || conflicts != 1 {
t.Errorf("codes = %d and %d", first, second)
}
for _, name := range []string{rig.assetName, "checksums.txt", "checksums.txt.sig"} {
if rig.fetcher.callsFor(name) != 1 {
t.Errorf("%s fetched %d times", name, rig.fetcher.callsFor(name))
}
}
})
t.Run("Try again after a failure", func(t *testing.T) {
rig := newDownloadRig(t)
failedAt := time.Date(2026, 9, 29, 8, 0, 0, 0, time.UTC)
rig.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed, Error: update.ErrorKindDownload, StartedAt: failedAt})
if err := os.MkdirAll(rig.stagedDir(), 0o755); err != nil {
t.Fatal(err)
}
stale := filepath.Join(rig.stagedDir(), "left-over")
if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := decodeUpdateBody(t, rec)
if rec.Code != http.StatusAccepted || body["error"] != nil || body["started_at"] == "2026-09-29T08:00:00Z" || body["state"] != "downloading" {
t.Fatalf("status = %d body = %v", rec.Code, body)
}
waitForHTTPState(t, h, "ready", "failed")
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Error("the old staged files must be replaced")
}
})
t.Run("Start is rate limited per user", func(t *testing.T) {
rig := newDownloadRig(t)
rig.helper.installed = false
h := newDownloadHTTP(t, rig)
authLimited := middleware.NewRateLimiter(5, time.Minute)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
for attempt := 1; attempt <= 5; attempt++ {
if rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId); rec.Code != http.StatusConflict {
t.Fatalf("attempt %d status = %d", attempt, rec.Code)
}
}
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
retryAfter, err := strconv.Atoi(rec.Header().Get("Retry-After"))
if rec.Code != http.StatusTooManyRequests || rec.Body.String() != apperr.TooManyRequestsErrorMessage+"\n" || err != nil || retryAfter < 1 {
t.Errorf("status = %d body = %q retry-after = %q", rec.Code, rec.Body.String(), rec.Header().Get("Retry-After"))
}
if other := h.sendAs(t, http.MethodPost, downloadPath, updateSecondOwner); other.Code != http.StatusConflict {
t.Errorf("another owner status = %d", other.Code)
}
for attempt := 1; attempt <= 5; attempt++ {
if check := h.sendAs(t, http.MethodPost, "/update/check", updateOwnerId); check.Code != http.StatusOK {
t.Errorf("check limiter was consumed: attempt %d status = %d", attempt, check.Code)
}
}
for attempt := 1; attempt <= 5; attempt++ {
recorder := httptest.NewRecorder()
authLimited.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/login", nil))
if recorder.Code != http.StatusOK {
t.Errorf("auth limiter attempt %d status = %d", attempt, recorder.Code)
}
}
})
}
func assertNothingStarted(t *testing.T, rig *downloadRig, rec *httptest.ResponseRecorder, status int) {
t.Helper()
if rec.Code != status {
t.Errorf("status = %d, want %d", rec.Code, status)
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
}
func TestUpdatePreflightRefusalsHTTP(t *testing.T) {
conditions := []struct {
name string
apply func(rig *downloadRig)
}{
{"no asset is named for the host platform", func(rig *downloadRig) { rig.dropAsset(rig.assetName) }},
{"checksums.txt is missing from the assets", func(rig *downloadRig) { rig.dropAsset("checksums.txt") }},
{"checksums.txt.sig is missing from the assets", func(rig *downloadRig) { rig.dropAsset("checksums.txt.sig") }},
{"an asset URL is http, not https", func(rig *downloadRig) {
rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.URL = "http://git.example/dl/" + asset.Name })
}},
{"an asset URL is on a host other than UPDATE_FEED_URL's host", func(rig *downloadRig) {
rig.setAsset("checksums.txt", func(asset *update.Asset) { asset.URL = "https://evil.example/dl/" + asset.Name })
}},
}
for _, condition := range conditions {
t.Run(condition.name, func(t *testing.T) {
rig := newDownloadRig(t)
condition.apply(rig)
h := newDownloadHTTP(t, rig)
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != apperr.UpdateRefusedErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
for _, leak := range []string{"git.example", "evil.example", "renewcms_", "checksums"} {
if strings.Contains(rec.Body.String(), leak) {
t.Errorf("body leaks %q", leak)
}
}
if _, found := rig.jobs.Load(); found || rig.fetcher.totalCalls() != 0 {
t.Error("no job may start and the fetcher must not be called")
}
if !strings.Contains(rig.logger.text(), "refused") {
t.Errorf("the reason must be logged, log: %q", rig.logger.text())
}
})
}
}
type jobFault struct {
name string
apply func(rig *downloadRig)
logged string
}
func runFaultTable(t *testing.T, wantError string, faults []jobFault) {
t.Helper()
for _, fault := range faults {
t.Run(fault.name, func(t *testing.T) {
rig := newDownloadRig(t)
fault.apply(rig)
h := newDownloadHTTP(t, rig)
post := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if post.Code != http.StatusAccepted {
t.Fatalf("status = %d: %s", post.Code, post.Body.String())
}
rec := h.sendAs(t, http.MethodGet, "/update", updateOwnerId)
body := waitForHTTPState(t, h, "failed", "ready")
if body["state"] != "failed" || body["error"] != wantError || body["progress"] != nil {
t.Fatalf("body = %v, log: %s", body, rig.logger.text())
}
assertStagedDirGone(t, rig)
if !strings.Contains(rig.logger.text(), fault.logged) {
t.Errorf("log %q must contain %q", rig.logger.text(), fault.logged)
}
for _, response := range []string{post.Body.String(), rec.Body.String(), h.sendAs(t, http.MethodGet, "/update", updateOwnerId).Body.String()} {
if strings.Contains(response, fault.logged) {
t.Errorf("a response leaks %q", fault.logged)
}
}
for _, state := range rig.jobs.states() {
if state == update.StateReady {
t.Error("a failed job must never have been ready")
}
}
})
}
}
func TestUpdateVerificationFailuresHTTP(t *testing.T) {
runFaultTable(t, "verification", []jobFault{
{"the binary was changed after signing (SHA-256 mismatch)", func(rig *downloadRig) {
tampered := append([]byte(nil), rig.fetcher.files[rig.assetName]...)
tampered[0] ^= 0xff
rig.fetcher.files[rig.assetName] = tampered
}, "sha-256 mismatch"},
{"checksums.txt was changed after signing", func(rig *downloadRig) {
tampered := []byte(strings.Replace(string(rig.fetcher.files["checksums.txt"]), "renewcms_", "renewcmz_", 1))
rig.fetcher.files["checksums.txt"] = tampered
}, "signature"},
{"the signature is from a different key", func(rig *downloadRig) {
_, other, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
rig.signChecksums(string(rig.fetcher.files["checksums.txt"]), other)
}, "does not match"},
{"checksums.txt has no line for the asset", func(rig *downloadRig) {
rig.signChecksums(rig.checksumsFor("renewcms_other_file", []byte("x")), rig.privateKey)
rig.refreshAssetSizes()
}, "has no line"},
{"the staged binary prints v2.2.9-beta for --version", func(rig *downloadRig) {
rig.runner.output = "v2.2.9-beta"
}, "v2.2.9-beta"},
{"the staged checksums cannot be read back", func(rig *downloadRig) {
rig.staging.readErr = errDownloadDisk
}, "no space left on device"},
{"the staged binary cannot be made executable", func(rig *downloadRig) {
rig.staging.chmodErr = errDownloadChmod
}, "operation not permitted"},
{"the staged binary cannot be run", func(rig *downloadRig) {
rig.runner.err = errDownloadRunnerFail
}, "exec format error"},
})
}
func TestUpdateDownloadFailuresHTTP(t *testing.T) {
runFaultTable(t, "download", []jobFault{
{"the connection drops after half the bytes (truncated download)", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
rig.fetcher.partial[rig.assetName] = true
}, "connection reset by peer"},
{"the bytes received differ from the size the release declares", func(rig *downloadRig) {
rig.setAsset(rig.assetName, func(asset *update.Asset) { asset.Size += 7 })
}, "the release declares"},
{"a file exceeds its size cap", func(rig *downloadRig) {
rig.fetcher.errors["checksums.txt"] = errDownloadTooLarge
}, "size cap"},
{"the download passes the overall timeout", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = context.DeadlineExceeded
}, "deadline exceeded"},
{"the feed host answers 404 or 500 for an asset", func(rig *downloadRig) {
rig.fetcher.errors["checksums.txt.sig"] = errAssetStatusNotFound
}, "answered 404"},
{"the updates directory cannot be created or written (disk error)", func(rig *downloadRig) {
if err := os.WriteFile(rig.updatesDir, []byte("a file where the directory should be"), 0o644); err != nil {
t.Fatal(err)
}
}, "updates"},
{"the staging directory cannot be prepared (injected)", func(rig *downloadRig) {
rig.staging.prepareErr = errDownloadDisk
}, "no space left on device"},
{"a staged file cannot be measured", func(rig *downloadRig) {
rig.staging.sizeErr = errDownloadStat
}, "stat failed"},
{"the disk fills during the write", func(rig *downloadRig) {
rig.fetcher.errors[rig.assetName] = errDownloadDisk
rig.fetcher.partial[rig.assetName] = true
}, "no space left on device"},
{"the database backup fails", func(rig *downloadRig) {
rig.backup.err = errDownloadBackup
}, "vacuum into failed"},
})
}
func TestUpdateBackupBeforeReadyHTTP(t *testing.T) {
t.Run("The backup comes before ready, and the last 3 are kept", func(t *testing.T) {
rig := newDownloadRig(t)
backups := filepath.Join(rig.dataDir, "backups")
if err := os.MkdirAll(backups, 0o755); err != nil {
t.Fatal(err)
}
for index, name := range []string{"earlier-1.db", "earlier-2.db", "earlier-3.db"} {
path := filepath.Join(backups, name)
if err := os.WriteFile(path, []byte("older copy"), 0o600); err != nil {
t.Fatal(err)
}
backdate(t, path, time.Duration(10-index)*time.Hour)
}
rig.realBackup = infraUpdate.NewSQLiteDatabaseBackup(newBackupTestDatabase(t), backups, time.Now)
rig.rebuild()
h := newDownloadHTTP(t, rig)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := waitForHTTPState(t, h, "ready", "failed")
if body["state"] != "ready" {
t.Fatalf("body = %v, log: %s", body, rig.logger.text())
}
entries, err := os.ReadDir(backups)
if err != nil || len(entries) != 3 {
t.Fatalf("backups = %d, err = %v", len(entries), err)
}
if _, err := os.Stat(filepath.Join(backups, "earlier-1.db")); !os.IsNotExist(err) {
t.Error("the oldest backup must be deleted")
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), "renewcms-") {
if rows := countBackupRows(t, filepath.Join(backups, entry.Name())); rows != 2 {
t.Errorf("rows in the new copy = %d, want 2", rows)
}
}
}
})
t.Run("a failed backup leaves state failed and does not prune", func(t *testing.T) {
rig := newDownloadRig(t)
rig.backup.err = errDownloadBackup
h := newDownloadHTTP(t, rig)
h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
body := waitForHTTPState(t, h, "ready", "failed")
if body["state"] != "failed" || body["error"] != "download" {
t.Errorf("body = %v", body)
}
})
}
func TestFailedUpdateJobRemovesItsStagedDirectoryEvenWhenRemovalFails(t *testing.T) {
rig := newDownloadRig(t)
rig.fetcher.errors[rig.assetName] = errDownloadNetwork
rig.staging.removeErr = errDownloadDisk
if _, err := rig.start.Start(); err != nil {
t.Fatal(err)
}
job := rig.waitForJob(update.StateFailed)
if job.Error != update.ErrorKindDownload || job.StagedBinary != "" {
t.Errorf("job = %+v", job)
}
if got := rig.staging.removed(); len(got) != 1 || got[0] != downloadTag {
t.Errorf("removed tags = %v", got)
}
}
func TestOSStagingArea(t *testing.T) {
root := filepath.Join(t.TempDir(), "updates")
area := infraUpdate.NewOSStagingArea(root)
t.Run("paths are rooted under the tag", func(t *testing.T) {
if got, want := area.PathFor("v1", "a.bin"), filepath.Join(root, "v1", "a.bin"); got != want {
t.Errorf("path = %q, want %q", got, want)
}
})
t.Run("prepare empties and recreates the directory", func(t *testing.T) {
if err := area.Prepare("v1"); err != nil {
t.Fatal(err)
}
stale := area.PathFor("v1", "stale")
if err := os.WriteFile(stale, []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
if err := area.Prepare("v1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Errorf("stale file survived: %v", err)
}
if info, err := os.Stat(filepath.Join(root, "v1")); err != nil || !info.IsDir() {
t.Errorf("directory missing: %v", err)
}
})
t.Run("size and checksums read the staged file", func(t *testing.T) {
path := area.PathFor("v1", "checksums.txt")
if err := os.WriteFile(path, []byte("abc"), 0o644); err != nil {
t.Fatal(err)
}
if size, err := area.Size(path); err != nil || size != 3 {
t.Errorf("size = %d, err = %v", size, err)
}
if content, err := area.ReadChecksums(path); err != nil || content != "abc" {
t.Errorf("content = %q, err = %v", content, err)
}
missing := area.PathFor("v1", "missing")
if _, err := area.Size(missing); err == nil {
t.Error("size of a missing file must fail")
}
if _, err := area.ReadChecksums(missing); err == nil {
t.Error("reading a missing file must fail")
}
})
t.Run("make executable sets the mode and fails on a missing file", func(t *testing.T) {
path := area.PathFor("v1", "binary")
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := area.MakeExecutable(path); err != nil {
t.Fatal(err)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0o755 {
t.Errorf("mode = %v, err = %v", info.Mode(), err)
}
}
if err := area.MakeExecutable(area.PathFor("v1", "missing")); err == nil {
t.Error("a missing file must fail")
}
})
t.Run("remove deletes the tag directory only", func(t *testing.T) {
if err := area.Prepare("v2"); err != nil {
t.Fatal(err)
}
if err := area.Remove("v1"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(root, "v1")); !os.IsNotExist(err) {
t.Errorf("v1 still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "v2")); err != nil {
t.Errorf("v2 must remain: %v", err)
}
})
t.Run("prepare fails when a file sits where the root should be", func(t *testing.T) {
blocked := filepath.Join(t.TempDir(), "updates")
if err := os.WriteFile(blocked, []byte("file"), 0o644); err != nil {
t.Fatal(err)
}
if err := infraUpdate.NewOSStagingArea(blocked).Prepare("v1"); err == nil {
t.Error("prepare must fail")
}
})
}
File diff suppressed because it is too large. Load diff
+18 -11
View File
@@ -20,11 +20,12 @@ import (
)
const (
updateOwnerId = 1
updateAdminId = 2
updateUnverifiedId = 3
updateSecondOwner = 4
updateRunning = "v2.2.0-beta"
updateOwnerId = 1
updateAdminId = 2
updateUnverifiedId = 3
updateSecondOwner = 4
updateUnverifiedOwner = 5
updateRunning = "v2.2.0-beta"
)
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "latest", "notes", "progress", "release_url", "started_at", "state"}
@@ -36,14 +37,20 @@ type updateHarness struct {
store *fakeUpdateStore
}
func newUpdateHarness(t *testing.T, running string) *updateHarness {
t.Helper()
validator.InitValidator()
func newUpdateUserRepo() *mockUserRepo {
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}
users.users[updateUnverifiedOwner] = user.User{ID: updateUnverifiedOwner, IsVerified: false, Role: user.RoleOwner}
return users
}
func newUpdateHarness(t *testing.T, running string) *updateHarness {
t.Helper()
validator.InitValidator()
users := newUpdateUserRepo()
feed := &fakeReleaseFeed{release: update.Release{
Tag: "v2.3.0-beta",
URL: "https://git.example/fsylvain/RenewCMS/releases/tag/v2.3.0-beta",
@@ -56,7 +63,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, running),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
@@ -306,9 +313,9 @@ func TestUpdateCheckHTTP(t *testing.T) {
}
})
t.Run("download and restart are not mounted", func(t *testing.T) {
t.Run("restart is not mounted", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
for _, path := range []string{"/update/download", "/update/restart"} {
for _, path := range []string{"/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)
}
+3 -3
View File
@@ -154,7 +154,7 @@ func TestReleaseNotesStripInlineMarkdown(t *testing.T) {
func TestGetUpdateStatusUseCase(t *testing.T) {
t.Run("never checked", func(t *testing.T) {
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, "v2.2.0-beta").GetStatus()
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{}, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true)
if status.Current != "v2.2.0-beta" || status.Latest != "" || status.Available {
t.Errorf("status = %+v", status)
@@ -170,7 +170,7 @@ func TestGetUpdateStatusUseCase(t *testing.T) {
t.Run("stored result", func(t *testing.T) {
store := &fakeUpdateStore{result: newStoredUpdateResult(), found: true}
status := useCases.NewGetUpdateStatusUseCase(store, "v2.2.0-beta").GetStatus()
status := useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true)
if status.Latest != "v2.3.0-beta" || !status.Available || status.CanApply || status.State != update.StateIdle {
t.Errorf("status = %+v", status)
@@ -185,7 +185,7 @@ func TestGetUpdateStatusUseCase(t *testing.T) {
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 {
if useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "dev").GetStatus(true).Available {
t.Error("a dev build must not report an update")
}
})