feat: restart to apply update, privileged helper, rollback

This commit is contained in:
fsylvain committed 2026-10-01 21:36:40 +02:00
1 parent 70460e4170
commit 54e8fd5fed
63 files changed
+8635 -164

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
package test
import (
"RenewCMS/internal/domain/update"
"bytes"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func runBinary(t *testing.T, binary string, workDir string, args ...string) (string, string, int) {
t.Helper()
command := exec.Command(binary, args...)
command.Dir = workDir
command.Env = emptyEnvironment()
var stdout, stderr bytes.Buffer
command.Stdout, command.Stderr = &stdout, &stderr
err := command.Run()
code := 0
if exitErr, ok := err.(*exec.ExitError); ok {
code = exitErr.ExitCode()
} else if err != nil {
t.Fatal(err)
}
return stdout.String(), stderr.String(), code
}
func assertEmptyDirectory(t *testing.T, directory string) {
t.Helper()
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
t.Errorf("the working directory must stay empty, got %d entries", len(entries))
}
}
func TestCommandDispatcher(t *testing.T) {
binary := buildServerBinary(t, "-X main.version=v9.9.9")
t.Run("--version prints the version and exits 0", func(t *testing.T) {
workDir := t.TempDir()
stdout, _, code := runBinary(t, binary, workDir, "--version")
if code != 0 || strings.TrimSpace(stdout) != "v9.9.9" {
t.Errorf("code = %d, stdout = %q", code, stdout)
}
assertEmptyDirectory(t, workDir)
})
t.Run("an unknown subcommand prints a usage message and does not start the server", func(t *testing.T) {
workDir := t.TempDir()
stdout, stderr, code := runBinary(t, binary, workDir, "frobnicate")
if code == 0 || !strings.Contains(stderr, "usage:") || stdout != "" {
t.Errorf("code = %d, stdout = %q, stderr = %q", code, stdout, stderr)
}
assertEmptyDirectory(t, workDir)
})
t.Run("apply-update runs without .env and creates no data directory", func(t *testing.T) {
workDir := t.TempDir()
_, stderr, code := runBinary(t, binary, workDir, "apply-update")
if code == 0 || strings.Contains(stderr, "PUBLIC_URL") || strings.Contains(stderr, ".env") {
t.Errorf("code = %d, stderr = %q", code, stderr)
}
assertEmptyDirectory(t, workDir)
})
t.Run("apply-update with its flags never reads .env or opens the database", func(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("on Linux the helper would act on the machine")
}
workDir := t.TempDir()
_, stderr, _ := runBinary(t, binary, workDir, "apply-update", "--binary", filepath.Join(workDir, "bin"), "--data-dir", filepath.Join(workDir, "data"))
if strings.Contains(stderr, "PUBLIC_URL") {
t.Errorf("stderr = %q", stderr)
}
if _, err := os.Stat(filepath.Join(workDir, "data")); err == nil {
t.Error("no data directory may be created")
}
})
t.Run("enable-browser-updates and disable-browser-updates refuse off Linux", func(t *testing.T) {
if runtime.GOOS == "linux" {
t.Skip("on Linux these commands would act on the machine's systemd")
}
workDir := t.TempDir()
_, stderr, code := runBinary(t, binary, workDir, "enable-browser-updates")
stdout, _, disableCode := runBinary(t, binary, workDir, "disable-browser-updates")
if code == 0 || strings.TrimSpace(stderr) != update.ErrNeedsLinux.Error() {
t.Errorf("enable: code = %d, stderr = %q", code, stderr)
}
if disableCode != 0 || !strings.Contains(stdout, "not enabled") {
t.Errorf("disable: code = %d, stdout = %q", disableCode, stdout)
}
assertEmptyDirectory(t, workDir)
})
t.Run("no arguments is the normal server start", func(t *testing.T) {
workDir := t.TempDir()
_, stderr, code := runBinary(t, binary, workDir)
if code == 0 || !strings.Contains(stderr, "PUBLIC_URL") {
t.Errorf("code = %d, stderr = %q", code, stderr)
}
})
}
func freeTCPPort(t *testing.T) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
return fmt.Sprint(listener.Addr().(*net.TCPAddr).Port)
}
func TestServerWritesRunningInfoBeforeServing(t *testing.T) {
if os.Getenv("RENEWCMS_TEST_SPAWN_SERVER") == "" {
t.Skip("spawns the real server, which triggers Windows firewall prompts; set RENEWCMS_TEST_SPAWN_SERVER=1 to run")
}
binary := buildServerBinary(t, "-X main.version=v9.9.9")
workDir := t.TempDir()
dataDir := filepath.Join(workDir, "data")
port := freeTCPPort(t)
command := exec.Command(binary)
command.Dir = workDir
command.Env = append(emptyEnvironment(),
"ENVIRONMENT=development", "PORT="+port, "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-dispatch",
"SMTP_LOGIN=a@example.com", "SMTP_SENDER=a@example.com", "SMTP_PASSWORD=x", "SMTP_HOST=localhost", "SMTP_PORT=2525",
"DB_FILE="+filepath.Join(dataDir, "renewcms.db"), "UPLOAD_DIR="+filepath.Join(dataDir, "uploads"),
"UPDATE_FEED_URL=https://git.example")
var stderr bytes.Buffer
command.Stderr = &stderr
started := time.Now()
if err := command.Start(); err != nil {
t.Fatal(err)
}
defer func() {
_ = command.Process.Kill()
_, _ = command.Process.Wait()
}()
healthy := false
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
response, err := http.Get("http://127.0.0.1:" + port + "/healthz")
if err == nil {
response.Body.Close()
healthy = response.StatusCode == http.StatusOK
if healthy {
break
}
}
}
if !healthy {
t.Fatalf("the server never answered /healthz: %s", stderr.String())
}
content, err := os.ReadFile(filepath.Join(dataDir, update.RunningInfoFileName))
if err != nil {
t.Fatalf("update-running.json must exist as soon as /healthz answers: %v", err)
}
var info update.RunningInfo
if err := json.Unmarshal(content, &info); err != nil {
t.Fatal(err)
}
if info.Version != "v9.9.9" || info.Pid != command.Process.Pid || info.StartedAt.Before(started.Add(-2*time.Second)) || info.StartedAt.After(time.Now()) {
t.Errorf("info = %+v, pid = %d", info, command.Process.Pid)
}
}
+10 -4
View File
@@ -305,6 +305,8 @@ type downloadRig struct {
store *fakeUpdateStore
jobs *fakeJobStore
helper *fakeHelperDetector
results *fakeResultStore
requests *fakeRestartRequestStore
fetcher *fakeAssetFetcher
runner *fakeStagedRunner
backup *fakeDatabaseBackup
@@ -337,6 +339,8 @@ func newDownloadRigFor(t *testing.T, platform update.Platform, binary []byte) *d
store: &fakeUpdateStore{},
jobs: &fakeJobStore{},
helper: &fakeHelperDetector{installed: true},
results: &fakeResultStore{},
requests: &fakeRestartRequestStore{},
fetcher: &fakeAssetFetcher{
files: map[string][]byte{},
errors: map[string]error{},
@@ -369,6 +373,8 @@ func (r *downloadRig) rebuild() {
Store: r.store,
Jobs: r.jobs,
Helper: r.helper,
Results: r.results,
Requests: r.requests,
Fetcher: r.fetcher,
Verifier: verifier,
Runner: r.runner,
@@ -380,7 +386,7 @@ func (r *downloadRig) rebuild() {
FeedURL: downloadFeedURL,
Now: func() time.Time { return updateTestCheckedAt },
})
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, updateRunning)
r.status = useCases.NewGetUpdateStatusUseCase(r.store, r.jobs, r.helper, r.results, r.requests, updateRunning, time.Now)
}
func (r *downloadRig) checksumsFor(name string, content []byte) string {
@@ -690,7 +696,7 @@ func TestJSONJobStorePersistence(t *testing.T) {
path := filepath.Join(dir, "update-job.json")
_ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: startedAt, StagedBinary: binary})
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true)
status := newUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true)
if status.State != update.StateReady {
t.Errorf("state = %q", status.State)
@@ -702,7 +708,7 @@ func TestJSONJobStorePersistence(t *testing.T) {
path := filepath.Join(t.TempDir(), "update-job.json")
_ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: state, StartedAt: startedAt, DownloadedBytes: 4, TotalBytes: 9})
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true)
status := newUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true)
if status.State != update.StateFailed || status.Error == nil || *status.Error != "download" || status.Progress != nil {
t.Errorf("%s: status = %+v", state, status)
@@ -714,7 +720,7 @@ func TestJSONJobStorePersistence(t *testing.T) {
path := filepath.Join(t.TempDir(), "update-job.json")
_ = infraUpdate.NewJSONJobStore(path).Save(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: startedAt, StagedBinary: filepath.Join(t.TempDir(), "missing")})
status := useCases.NewGetUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true)
status := newUpdateStatusUseCase(&fakeUpdateStore{result: newStoredUpdateResult(), found: true}, infraUpdate.NewJSONJobStore(path), &fakeHelperDetector{}, updateRunning).GetStatus(true)
if status.State != update.StateIdle || status.StartedAt != nil {
t.Errorf("status = %+v", status)
+188
View File
@@ -0,0 +1,188 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
func TestRestartRequestJSONStore(t *testing.T) {
t.Run("it round trips and replaces atomically", func(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, update.RestartRequestFileName)
store := infraUpdate.NewJSONRestartRequestStore(path)
if _, err := store.Read(); !errors.Is(err, update.ErrNoRestartRequest) {
t.Fatalf("an absent marker = %v", err)
}
first := update.RestartRequest{Kind: "update", Tag: downloadTag, RequestedAt: restartBaseTime, RequestedBy: 7}
second := update.RestartRequest{Kind: "restart", RequestedAt: restartBaseTime.Add(time.Minute), RequestedBy: 8}
if err := store.Write(first); err != nil {
t.Fatal(err)
}
if err := store.Write(second); err != nil {
t.Fatal(err)
}
got, err := store.Read()
if err != nil || got != second {
t.Errorf("got = %+v, err = %v", got, err)
}
if entries, _ := os.ReadDir(directory); len(entries) != 1 {
t.Errorf("no temporary file may remain, got %d entries", len(entries))
}
if err := store.Delete(); err != nil {
t.Fatal(err)
}
if err := store.Delete(); err != nil {
t.Errorf("deleting twice = %v", err)
}
if _, err := store.Read(); !errors.Is(err, update.ErrNoRestartRequest) {
t.Errorf("after delete = %v", err)
}
})
t.Run("a corrupt marker still counts as present", func(t *testing.T) {
path := filepath.Join(t.TempDir(), update.RestartRequestFileName)
_ = os.WriteFile(path, []byte("{nope"), 0o600)
_, err := infraUpdate.NewJSONRestartRequestStore(path).Read()
if err == nil || errors.Is(err, update.ErrNoRestartRequest) {
t.Errorf("err = %v", err)
}
})
}
func TestResultJSONStore(t *testing.T) {
t.Run("it round trips, keeps null finished_at and ignores corrupt or unknown files", func(t *testing.T) {
path := filepath.Join(t.TempDir(), update.ResultFileName)
store := infraUpdate.NewJSONResultStore(path)
if _, found := store.Read(); found {
t.Fatal("absent file")
}
running := update.Result{Outcome: update.OutcomeRunning, Kind: "update", Tag: downloadTag, FromVersion: helperOld, StartedAt: restartBaseTime}
if err := store.Write(running); err != nil {
t.Fatal(err)
}
got, found := store.Read()
if !found || got.Outcome != "running" || got.FinishedAt != nil || !got.StartedAt.Equal(restartBaseTime) {
t.Errorf("got = %+v", got)
}
for name, content := range map[string]string{"corrupt": "{nope", "unknown outcome": `{"outcome":"weird"}`, "empty": ""} {
_ = os.WriteFile(path, []byte(content), 0o644)
if _, found := store.Read(); found {
t.Errorf("%s must count as no result", name)
}
}
_ = store.Write(running)
if err := store.Delete(); err != nil {
t.Fatal(err)
}
if _, found := store.Read(); found {
t.Error("deleted")
}
if err := store.Delete(); err != nil {
t.Errorf("deleting twice = %v", err)
}
})
}
func TestRunningInfoJSONStore(t *testing.T) {
t.Run("it round trips and rejects missing, corrupt and incomplete files", func(t *testing.T) {
path := filepath.Join(t.TempDir(), update.RunningInfoFileName)
store := infraUpdate.NewJSONRunningInfoStore(path)
if _, found := store.Read(); found {
t.Fatal("absent file")
}
info := update.RunningInfo{Version: downloadTag, Pid: 12, StartedAt: restartBaseTime}
if err := store.Write(info); err != nil {
t.Fatal(err)
}
if got, found := store.Read(); !found || got != info {
t.Errorf("got = %+v", got)
}
for name, content := range map[string]string{"corrupt": "{nope", "no pid": `{"version":"v1.0.0"}`, "no version": `{"pid":3}`} {
_ = os.WriteFile(path, []byte(content), 0o644)
if _, found := store.Read(); found {
t.Errorf("%s must not be read as running info", name)
}
}
})
t.Run("a symlink is never read", func(t *testing.T) {
directory := t.TempDir()
target := filepath.Join(directory, "target.json")
_ = os.WriteFile(target, []byte(`{"version":"v9.9.9","pid":1,"started_at":"2026-09-30T09:50:00Z"}`), 0o644)
link := filepath.Join(directory, update.RunningInfoFileName)
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks are not available: %v", err)
}
if _, found := infraUpdate.NewJSONRunningInfoStore(link).Read(); found {
t.Error("a symlink must not count")
}
})
}
func TestHelperStatusFile(t *testing.T) {
t.Run("it is written world readable so the app can detect it", func(t *testing.T) {
directory := t.TempDir()
writer := infraUpdate.NewJSONHelperStatusWriter()
err := writer.Write(directory, update.HelperStatus{Version: 1, Service: "renewcms", Binary: "/opt/renewcms/renewcms", DataDir: directory, InstalledAt: restartBaseTime})
path := filepath.Join(directory, update.HelperStatusFileName)
if err != nil || !infraUpdate.NewFileHelperDetector(path).Installed() {
t.Fatalf("err = %v", err)
}
if info, _ := os.Stat(path); info.Mode().Perm()&0o044 != 0o044 && filepath.Separator == '/' {
t.Errorf("mode = %v", info.Mode())
}
removed, err := writer.Remove(directory)
if err != nil || !removed {
t.Errorf("removed = %v, err = %v", removed, err)
}
removed, err = writer.Remove(directory)
if err != nil || removed {
t.Errorf("a second removal = %v, %v", removed, err)
}
})
}
func TestServiceUnitParsing(t *testing.T) {
t.Run("it reads the binary, the data directory and the environment file", func(t *testing.T) {
unit := "[Service]\nUser=renewcms\nEnvironmentFile=/etc/renewcms/renewcms.env\nExecStart=/opt/renewcms/renewcms\nProtectSystem=strict\nReadWritePaths=/var/lib/renewcms\n"
got := infraUpdate.ParseServiceUnit(unit)
if got.BinaryPath != "/opt/renewcms/renewcms" || got.DataDir != "/var/lib/renewcms" || got.EnvironmentFile != "/etc/renewcms/renewcms.env" {
t.Errorf("got = %+v", got)
}
})
t.Run("systemd prefixes are stripped", func(t *testing.T) {
unit := "ExecStart=-@/opt/renewcms/renewcms --flag\nEnvironmentFile=-/etc/x.env\nReadWritePaths=-/var/lib/renewcms /other\n"
got := infraUpdate.ParseServiceUnit(unit)
if got.BinaryPath != "/opt/renewcms/renewcms" || got.EnvironmentFile != "/etc/x.env" || got.DataDir != "/var/lib/renewcms" {
t.Errorf("got = %+v", got)
}
})
}
func TestNoFollowFilesystemExists(t *testing.T) {
var files update.HelperFilesystem = infraUpdate.NewNoFollowFilesystem()
if files == nil {
t.Fatal("the adapter must compile on every platform")
}
}
+399
View File
@@ -0,0 +1,399 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"errors"
"net/http"
"net/http/httptest"
"os"
"path"
"runtime"
"strings"
"testing"
"time"
)
func TestApplyRefusesStagedEntriesThatAreNotRegularFiles(t *testing.T) {
for _, name := range []string{helperAsset, update.ChecksumsAssetName, update.SignatureAssetName} {
t.Run("a directory in place of "+name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
target := path.Join(helperStagedDir, name)
rig.forget(target)
rig.fs.putDir(target)
err := rig.apply()
if err != nil || rig.result().Outcome != update.OutcomeFailed {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
if !strings.Contains(rig.logger.text(), target+" is not a regular file") {
t.Errorf("the refusal must name the entry: %s", rig.logger.text())
}
if rig.fs.operationCount("tempdir") != 0 {
t.Error("nothing may be copied once a staged entry is refused")
}
rig.assertUntouched()
})
}
}
func TestApplyKeepsThePreviousBinaryOrFails(t *testing.T) {
t.Run("a failed rename of the previous-binary backup stops the install", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.failNth("rename", 2, errors.New("cross-device link"))
err := rig.apply()
if err != nil || rig.result().Outcome != update.OutcomeFailed || rig.service.restartCount() != 0 {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertBinary("old")
if rig.fs.exists(helperBinary+".previous") || rig.fs.exists(helperBinary+".previous.tmp") {
t.Error("no backup file may be left behind")
}
if !strings.Contains(rig.logger.text(), "keeping the previous binary: ") {
t.Errorf("log = %s", rig.logger.text())
}
})
t.Run("a failed rename of the rollback restore is reported loudly", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.service.failOn[1] = errors.New("restart failed")
rig.fs.failNth("rename", 4, errors.New("input/output error"))
err := rig.apply()
if err == nil || rig.result().Outcome != update.OutcomeFailed || rig.service.restartCount() != 1 {
t.Errorf("err = %v, result = %+v, restarts = %d", err, rig.result(), rig.service.restartCount())
}
if !strings.Contains(rig.logger.text(), "the site may be down") || rig.fs.exists(helperBinary+".restore") {
t.Errorf("log = %s", rig.logger.text())
}
})
}
func TestApplyRestartWindowBoundary(t *testing.T) {
cases := []struct {
name string
comesBack int
wantOK bool
}{
{"the site back one second before the window ends counts", 59, true},
{"the site back exactly when the window ends is too late", 60, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.service.onRestart = func(int) {}
rig.clock.onSleep = func(sleeps int) {
if sleeps == tc.comesBack {
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: rig.clock.Now().Add(time.Second)})
}
}
err := rig.apply()
if tc.wantOK && (err != nil || rig.result().Outcome != update.OutcomeSucceeded) {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
if !tc.wantOK && (err == nil || rig.result().Outcome != update.OutcomeFailed || rig.service.restartCount() != 1) {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
})
}
}
func TestApplyLogsOnlyRealFailures(t *testing.T) {
t.Run("a clean update logs neither a write nor a removal failure", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
if err := rig.apply(); err != nil {
t.Fatal(err)
}
for _, unwanted := range []string{"could not be written", "could not remove", "staged files left in place"} {
if strings.Contains(rig.logger.text(), unwanted) {
t.Errorf("unexpected log %q in %s", unwanted, rig.logger.text())
}
}
})
t.Run("a result that cannot be written is logged and does not stop the restart", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.fs.failNth("create", 1, errors.New("disk full"))
err := rig.apply()
if err != nil || rig.service.restartCount() != 1 {
t.Errorf("err = %v, restarts = %d", err, rig.service.restartCount())
}
if !strings.Contains(rig.logger.text(), "update-result.json could not be written: disk full") {
t.Errorf("log = %s", rig.logger.text())
}
})
t.Run("a temp directory that cannot be removed is logged and the update still succeeds", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.failNth("removeall", 1, errors.New("device busy"))
err := rig.apply()
if err != nil || rig.result().Outcome != update.OutcomeSucceeded {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
if !strings.Contains(rig.logger.text(), "could not remove /tmp/renewcms-update-1: device busy") {
t.Errorf("log = %s", rig.logger.text())
}
})
t.Run("staged files that are already gone at cleanup are not an error", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.after("removeall", 1, func(f *fakeHelperFS) {
for name := range f.nodes {
if name == helperStagedDir || strings.HasPrefix(name, helperStagedDir+"/") {
delete(f.nodes, name)
}
}
})
err := rig.apply()
if err != nil || rig.result().Outcome != update.OutcomeSucceeded {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
if strings.Contains(rig.logger.text(), "could not remove") || strings.Contains(rig.logger.text(), "staged files left in place") {
t.Errorf("log = %s", rig.logger.text())
}
if rig.fs.operationCount("removeall") != 1 {
t.Errorf("removeall calls = %d, a missing staged directory needs none", rig.fs.operationCount("removeall"))
}
})
}
func TestEnableAndDisableBrowserUpdatesEdges(t *testing.T) {
t.Run("a binary given by flag still needs a data directory or a known service", func(t *testing.T) {
rig := newInstallRig()
rig.locator.found = false
rig.locator.description = update.ServiceDescription{}
_, err := rig.enable.Enable(update.InstallOptions{Binary: "/opt/cms/renewcms"})
if !errors.Is(err, update.ErrServiceNotFound) || len(rig.events) != 0 {
t.Errorf("err = %v, events = %v", err, rig.events)
}
})
t.Run("every flag given makes an unknown service installable", func(t *testing.T) {
rig := newInstallRig()
rig.locator.found = false
rig.locator.description = update.ServiceDescription{}
_, err := rig.enable.Enable(update.InstallOptions{Binary: "/opt/cms/renewcms", DataDir: "/srv/cms", Port: "8081"})
if err != nil || len(rig.units.units) != 2 {
t.Errorf("err = %v, units = %v", err, rig.units.units)
}
if _, found := rig.status.status["/srv/cms"]; !found {
t.Error("the status file goes to the data directory given by flag")
}
})
t.Run("the port is accepted from 1 to 65535 and refused outside it", func(t *testing.T) {
for port, valid := range map[string]bool{"1": true, "65535": true, "0": false, "65536": false, "-1": false, "80a": false} {
rig := newInstallRig()
_, err := rig.enable.Enable(update.InstallOptions{Port: port})
if valid && err != nil {
t.Errorf("port %q: %v", port, err)
}
if !valid && !errors.Is(err, update.ErrInvalidPort) {
t.Errorf("port %q: err = %v", port, err)
}
}
})
t.Run("disable with the units present but no status file still disables", func(t *testing.T) {
rig := newInstallRig()
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
t.Fatal(err)
}
delete(rig.status.status, "/var/lib/renewcms")
rig.events = nil
message, err := rig.disable.Disable(update.InstallOptions{})
if err != nil || strings.Contains(message, "not enabled") || len(rig.units.units) != 0 {
t.Errorf("message = %q, err = %v, units = %v", message, err, rig.units.units)
}
assertInOrder(t, rig.events, "disable "+update.PathUnitName, "daemon-reload")
})
t.Run("disable with a status file but no units still disables", func(t *testing.T) {
rig := newInstallRig()
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
t.Fatal(err)
}
rig.units.units = map[string]string{}
rig.events = nil
message, err := rig.disable.Disable(update.InstallOptions{})
if err != nil || strings.Contains(message, "not enabled") || len(rig.status.status) != 0 {
t.Errorf("message = %q, err = %v", message, err)
}
})
}
func TestFSStoresReportRealFailures(t *testing.T) {
stores := map[string]func(files *fakeHelperFS) (write func() error, remove func() error){
"the result store": func(files *fakeHelperFS) (func() error, func() error) {
store := infraUpdate.NewFSResultStore(files, helperResult)
return func() error {
return store.Write(update.Result{Outcome: update.OutcomeFailed, StartedAt: restartBaseTime})
}, store.Delete
},
"the restart request store": func(files *fakeHelperFS) (func() error, func() error) {
store := infraUpdate.NewFSRestartRequestStore(files, helperMarker)
return func() error {
return store.Write(update.RestartRequest{Kind: update.RestartKindRestart, RequestedAt: restartBaseTime})
}, store.Delete
},
}
for name, build := range stores {
t.Run(name+": deleting a file that is not there is fine", func(t *testing.T) {
files := newFakeHelperFS()
files.putDir(helperData)
_, remove := build(files)
if err := remove(); err != nil {
t.Errorf("err = %v", err)
}
})
t.Run(name+": a delete that fails for another reason is returned", func(t *testing.T) {
files := newFakeHelperFS()
files.putDir(helperData)
files.failNth("remove", 1, errors.New("read-only file system"))
_, remove := build(files)
if err := remove(); err == nil || err.Error() != "read-only file system" {
t.Errorf("err = %v", err)
}
})
t.Run(name+": a failed rename is returned and the temp file is removed", func(t *testing.T) {
files := newFakeHelperFS()
files.putDir(helperData)
files.failNth("rename", 1, errors.New("cross-device link"))
write, _ := build(files)
err := write()
if err == nil || err.Error() != "cross-device link" {
t.Errorf("err = %v", err)
}
for node := range files.nodes {
if strings.HasSuffix(node, ".tmp") {
t.Errorf("the temp file %s must be removed", node)
}
}
})
t.Run(name+": a stale temp file that cannot be removed fails the write", func(t *testing.T) {
files := newFakeHelperFS()
files.putDir(helperData)
files.putFile(helperMarker+".tmp", "stale")
files.putFile(helperResult+".tmp", "stale")
files.failNth("remove", 1, errors.New("operation not permitted"))
write, _ := build(files)
err := write()
if err == nil || err.Error() != "operation not permitted" || files.operationCount("create") != 0 {
t.Errorf("err = %v, creates = %d", err, files.operationCount("create"))
}
})
}
}
func TestHTTPHealthChecker(t *testing.T) {
serve := func(status int) (string, func()) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/healthz" {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(status)
}))
return strings.TrimPrefix(server.URL, "http://127.0.0.1:"), server.Close
}
t.Run("a 200 answer on /healthz is healthy", func(t *testing.T) {
port, stop := serve(http.StatusOK)
defer stop()
if !infraUpdate.NewHTTPHealthChecker(port).Healthy() {
t.Error("a 200 must be healthy")
}
})
t.Run("any other status is not healthy", func(t *testing.T) {
for _, status := range []int{http.StatusNoContent, http.StatusInternalServerError, http.StatusServiceUnavailable} {
port, stop := serve(status)
healthy := infraUpdate.NewHTTPHealthChecker(port).Healthy()
stop()
if healthy {
t.Errorf("status %d must not be healthy", status)
}
}
})
t.Run("nothing listening is not healthy", func(t *testing.T) {
port, stop := serve(http.StatusOK)
stop()
if infraUpdate.NewHTTPHealthChecker(port).Healthy() {
t.Error("a closed port must not be healthy")
}
})
}
func TestSystemMachineProbe(t *testing.T) {
probe := infraUpdate.NewSystemMachineProbe()
t.Run("it reports the real OS and effective user", func(t *testing.T) {
if probe.OS() != runtime.GOOS {
t.Errorf("OS = %q, want %q", probe.OS(), runtime.GOOS)
}
if probe.IsRoot() != (os.Geteuid() == 0) {
t.Errorf("IsRoot = %v, euid = %d", probe.IsRoot(), os.Geteuid())
}
})
t.Run("it reports systemd only where its runtime directory exists", func(t *testing.T) {
info, err := os.Stat("/run/systemd/system")
want := err == nil && info.IsDir()
if probe.SystemdRunning() != want {
t.Errorf("SystemdRunning = %v, want %v", probe.SystemdRunning(), want)
}
})
}
+466
View File
@@ -0,0 +1,466 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type fakeMachineProbe struct {
os string
systemd bool
root bool
}
var _ update.MachineProbe = &fakeMachineProbe{}
func (f *fakeMachineProbe) OS() string { return f.os }
func (f *fakeMachineProbe) SystemdRunning() bool { return f.systemd }
func (f *fakeMachineProbe) IsRoot() bool { return f.root }
type fakeServiceLocator struct {
description update.ServiceDescription
found bool
port string
hasPort bool
}
var _ update.ServiceLocator = &fakeServiceLocator{}
func (f *fakeServiceLocator) Locate(name string) (update.ServiceDescription, bool) {
description := f.description
description.Name = name
return description, f.found
}
func (f *fakeServiceLocator) PortFromEnvironmentFile(string) (string, bool) {
return f.port, f.hasPort
}
type fakeUnitWriter struct {
units map[string]string
writes int
failNth int
events *[]string
}
var _ update.UnitWriter = &fakeUnitWriter{}
func (f *fakeUnitWriter) Write(name string, content string) error {
f.writes++
*f.events = append(*f.events, "write "+name)
if f.failNth == f.writes {
return errors.New("disk full")
}
f.units[name] = content
return nil
}
func (f *fakeUnitWriter) Remove(name string) (bool, error) {
_, existed := f.units[name]
delete(f.units, name)
*f.events = append(*f.events, "remove "+name)
return existed, nil
}
type fakeUnitManager struct {
reloadErr error
enableErr error
events *[]string
}
var _ update.UnitManager = &fakeUnitManager{}
func (f *fakeUnitManager) DaemonReload() error {
*f.events = append(*f.events, "daemon-reload")
return f.reloadErr
}
func (f *fakeUnitManager) EnableNow(unit string) error {
*f.events = append(*f.events, "enable "+unit)
return f.enableErr
}
func (f *fakeUnitManager) DisableNow(unit string) error {
*f.events = append(*f.events, "disable "+unit)
return nil
}
type fakeHelperStatusWriter struct {
status map[string]update.HelperStatus
events *[]string
}
var _ update.HelperStatusWriter = &fakeHelperStatusWriter{}
func (f *fakeHelperStatusWriter) Write(dataDir string, status update.HelperStatus) error {
*f.events = append(*f.events, "status")
f.status[dataDir] = status
return nil
}
func (f *fakeHelperStatusWriter) Remove(dataDir string) (bool, error) {
_, existed := f.status[dataDir]
delete(f.status, dataDir)
*f.events = append(*f.events, "status-removed")
return existed, nil
}
type installRig struct {
probe *fakeMachineProbe
locator *fakeServiceLocator
units *fakeUnitWriter
manager *fakeUnitManager
status *fakeHelperStatusWriter
events []string
enable *useCases.EnableBrowserUpdatesUseCase
disable *useCases.DisableBrowserUpdatesUseCase
}
func newInstallRig() *installRig {
rig := &installRig{
probe: &fakeMachineProbe{os: "linux", systemd: true, root: true},
locator: &fakeServiceLocator{
found: true,
description: update.ServiceDescription{
BinaryPath: "/opt/renewcms/renewcms",
DataDir: "/var/lib/renewcms",
EnvironmentFile: "/etc/renewcms/renewcms.env",
},
},
}
rig.units = &fakeUnitWriter{units: map[string]string{}, events: &rig.events}
rig.manager = &fakeUnitManager{events: &rig.events}
rig.status = &fakeHelperStatusWriter{status: map[string]update.HelperStatus{}, events: &rig.events}
rig.rebuild()
return rig
}
func (r *installRig) rebuild() {
dependencies := useCases.ManageBrowserUpdatesDependencies{
Probe: r.probe, Locator: r.locator, Units: r.units, Manager: r.manager, Status: r.status,
Now: func() time.Time { return restartBaseTime },
}
r.enable = useCases.NewEnableBrowserUpdatesUseCase(dependencies)
r.disable = useCases.NewDisableBrowserUpdatesUseCase(dependencies)
}
func TestEnableBrowserUpdatesRefusals(t *testing.T) {
cases := []struct {
name string
prepare func(*installRig)
message string
}{
{"the OS is not Linux", func(r *installRig) { r.probe.os = "windows" }, "Browser updates need Linux with systemd."},
{"Linux without systemd", func(r *installRig) { r.probe.systemd = false }, "Browser updates need systemd, which this machine is not running."},
{"not run as root", func(r *installRig) { r.probe.root = false }, "Run this command as root, for example with sudo."},
{"no unit for the service and no flags", func(r *installRig) { r.locator.found = false; r.locator.description = update.ServiceDescription{} }, "Could not find the RenewCMS service. Pass --service, --binary and --data-dir."},
{"the data directory cannot be resolved", func(r *installRig) { r.locator.description.DataDir = "" }, "Could not find the data directory. Pass --data-dir."},
}
for _, tc := range cases {
t.Run("enable-browser-updates refuses when it cannot work: "+tc.name, func(t *testing.T) {
rig := newInstallRig()
tc.prepare(rig)
_, err := rig.enable.Enable(update.InstallOptions{})
if err == nil || err.Error() != tc.message {
t.Errorf("err = %v, want %q", err, tc.message)
}
if len(rig.events) != 0 || len(rig.units.units) != 0 || len(rig.status.status) != 0 {
t.Errorf("nothing may be written: %v", rig.events)
}
})
}
}
func TestEnableBrowserUpdates(t *testing.T) {
t.Run("enable-browser-updates installs the units", func(t *testing.T) {
rig := newInstallRig()
rig.locator.port, rig.locator.hasPort = "9090", true
message, err := rig.enable.Enable(update.InstallOptions{})
if err != nil {
t.Fatal(err)
}
service := rig.units.units[update.ServiceUnitName]
path := rig.units.units[update.PathUnitName]
for _, want := range []string{"Type=oneshot", "User=root", "ExecStart=/opt/renewcms/renewcms apply-update", "--service renewcms", "--binary /opt/renewcms/renewcms", "--data-dir /var/lib/renewcms", "--port 9090"} {
if !strings.Contains(service, want) {
t.Errorf("service unit lacks %q:\n%s", want, service)
}
}
if !strings.Contains(path, "PathExists=/var/lib/renewcms/update-restart-request.json") || !strings.Contains(path, "Unit=renewcms-update.service") {
t.Errorf("path unit:\n%s", path)
}
assertInOrder(t, rig.events, "write", "write", "daemon-reload", "enable renewcms-update.path", "status")
if rig.events[len(rig.events)-1] != "status" {
t.Errorf("the status file must be written last: %v", rig.events)
}
status := rig.status.status["/var/lib/renewcms"]
if status.Version != 1 || status.Service != "renewcms" || status.Binary != "/opt/renewcms/renewcms" || status.DataDir != "/var/lib/renewcms" || !status.InstalledAt.Equal(restartBaseTime) {
t.Errorf("status = %+v", status)
}
if !strings.Contains(message, "renewcms disable-browser-updates") || !strings.Contains(message, update.PathUnitName) {
t.Errorf("message = %q", message)
}
})
t.Run("the port comes from --port, else the environment file, else 8080", func(t *testing.T) {
cases := []struct {
flag, env string
hasEnv bool
want string
}{
{"7000", "9090", true, "--port 7000"},
{"", "9090", true, "--port 9090"},
{"", "", false, "--port 8080"},
}
for _, tc := range cases {
rig := newInstallRig()
rig.locator.port, rig.locator.hasPort = tc.env, tc.hasEnv
if _, err := rig.enable.Enable(update.InstallOptions{Port: tc.flag}); err != nil {
t.Fatal(err)
}
if !strings.Contains(rig.units.units[update.ServiceUnitName], tc.want) {
t.Errorf("want %q in:\n%s", tc.want, rig.units.units[update.ServiceUnitName])
}
}
})
t.Run("flags override the unit", func(t *testing.T) {
rig := newInstallRig()
_, err := rig.enable.Enable(update.InstallOptions{Service: "cms", Binary: "/srv/cms bin/renewcms", DataDir: "/srv/data", Port: "81"})
service := rig.units.units[update.ServiceUnitName]
if err != nil || !strings.Contains(service, `"/srv/cms bin/renewcms" apply-update --service cms --binary "/srv/cms bin/renewcms" --data-dir /srv/data --port 81`) {
t.Errorf("err = %v, unit:\n%s", err, service)
}
if _, found := rig.status.status["/srv/data"]; !found {
t.Error("the status file goes to the overridden data directory")
}
})
t.Run("an invalid port is refused", func(t *testing.T) {
rig := newInstallRig()
_, err := rig.enable.Enable(update.InstallOptions{Port: "http"})
if !errors.Is(err, update.ErrInvalidPort) || len(rig.units.units) != 0 {
t.Errorf("err = %v", err)
}
})
t.Run("enable-browser-updates undoes its own partial work", func(t *testing.T) {
cases := map[string]func(*installRig){
"writing the second unit": func(r *installRig) { r.units.failNth = 2 },
"daemon-reload": func(r *installRig) { r.manager.reloadErr = errors.New("reload failed") },
"enable": func(r *installRig) { r.manager.enableErr = errors.New("enable failed") },
}
for name, fail := range cases {
t.Run(name, func(t *testing.T) {
rig := newInstallRig()
fail(rig)
_, err := rig.enable.Enable(update.InstallOptions{})
if err == nil || len(rig.units.units) != 0 || len(rig.status.status) != 0 {
t.Errorf("err = %v, units = %v, status = %v", err, rig.units.units, rig.status.status)
}
for _, event := range rig.events {
if event == "status" {
t.Error("update-helper.json must not be written")
}
}
})
}
})
t.Run("enable-browser-updates is idempotent", func(t *testing.T) {
rig := newInstallRig()
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
t.Fatal(err)
}
first := map[string]string{}
for name, content := range rig.units.units {
first[name] = content
}
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
t.Fatal(err)
}
if len(rig.units.units) != 2 || rig.units.units[update.ServiceUnitName] != first[update.ServiceUnitName] || rig.units.units[update.PathUnitName] != first[update.PathUnitName] {
t.Error("a second run must rewrite identical units")
}
rig.locator.description.BinaryPath = "/usr/local/bin/renewcms"
if _, err := rig.enable.Enable(update.InstallOptions{Service: "renewcms2"}); err != nil {
t.Fatal(err)
}
if len(rig.units.units) != 2 || !strings.Contains(rig.units.units[update.ServiceUnitName], "/usr/local/bin/renewcms") || !strings.Contains(rig.units.units[update.ServiceUnitName], "--service renewcms2") {
t.Errorf("units must follow the new values: %v", rig.units.units)
}
if rig.status.status["/var/lib/renewcms"].Binary != "/usr/local/bin/renewcms" {
t.Error("the status file follows the new values")
}
})
}
func TestBrowserUpdatesOverRealFiles(t *testing.T) {
setup := func(t *testing.T) (*installRig, string, string, string) {
t.Helper()
directory := t.TempDir()
unitDirectory := filepath.Join(directory, "units")
dataDir := filepath.Join(directory, "data")
envFile := filepath.Join(directory, "renewcms.env")
for _, path := range []string{unitDirectory, dataDir} {
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
}
environment := "ENVIRONMENT=production\nPORT=9191\nJWT_SECRET=sentinel-secret\nSMTP_PASSWORD=sentinel-smtp\n"
if err := os.WriteFile(envFile, []byte(environment), 0o600); err != nil {
t.Fatal(err)
}
unit := "[Service]\nUser=renewcms\nEnvironmentFile=-" + envFile + "\nExecStart=/opt/renewcms/renewcms\nReadWritePaths=" + dataDir + "\n"
if err := os.WriteFile(filepath.Join(unitDirectory, "renewcms.service"), []byte(unit), 0o644); err != nil {
t.Fatal(err)
}
rig := newInstallRig()
units := infraUpdate.NewSystemdUnitsIn(unitDirectory)
rig.locator = nil
dependencies := useCases.ManageBrowserUpdatesDependencies{
Probe: rig.probe, Locator: units, Units: units, Manager: rig.manager,
Status: infraUpdate.NewJSONHelperStatusWriter(), Now: func() time.Time { return restartBaseTime },
}
rig.enable = useCases.NewEnableBrowserUpdatesUseCase(dependencies)
rig.disable = useCases.NewDisableBrowserUpdatesUseCase(dependencies)
return rig, unitDirectory, dataDir, envFile
}
t.Run("it reads the unit and writes no secret and no environment value", func(t *testing.T) {
rig, unitDirectory, dataDir, _ := setup(t)
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
t.Fatal(err)
}
for _, name := range []string{update.ServiceUnitName, update.PathUnitName} {
content, err := os.ReadFile(filepath.Join(unitDirectory, name))
if err != nil {
t.Fatal(err)
}
for _, forbidden := range []string{"JWT_SECRET", "sentinel-secret", "sentinel-smtp", "SMTP_PASSWORD", "ENVIRONMENT"} {
if strings.Contains(string(content), forbidden) {
t.Errorf("%s contains %q", name, forbidden)
}
}
}
service, _ := os.ReadFile(filepath.Join(unitDirectory, update.ServiceUnitName))
if !strings.Contains(string(service), "--port 9191") || !strings.Contains(string(service), "--data-dir ") {
t.Errorf("service unit:\n%s", service)
}
if !infraUpdate.NewFileHelperDetector(filepath.Join(dataDir, update.HelperStatusFileName)).Installed() {
t.Error("can_apply must read true afterwards")
}
})
t.Run("disable-browser-updates", func(t *testing.T) {
rig, unitDirectory, dataDir, _ := setup(t)
if _, err := rig.enable.Enable(update.InstallOptions{}); err != nil {
t.Fatal(err)
}
kept := []string{update.RestartRequestFileName, update.ResultFileName, "updates/v2.3.0-beta/binary", "backups/renewcms-1.db"}
for _, name := range kept {
full := filepath.Join(dataDir, filepath.FromSlash(name))
_ = os.MkdirAll(filepath.Dir(full), 0o755)
if err := os.WriteFile(full, []byte("keep"), 0o644); err != nil {
t.Fatal(err)
}
}
rig.events = nil
message, err := rig.disable.Disable(update.InstallOptions{})
if err != nil || !strings.Contains(message, "disabled") {
t.Fatalf("message = %q, err = %v", message, err)
}
for _, name := range []string{update.PathUnitName, update.ServiceUnitName} {
if _, statErr := os.Stat(filepath.Join(unitDirectory, name)); statErr == nil {
t.Errorf("%s must be removed", name)
}
}
assertInOrder(t, rig.events, "disable renewcms-update.path", "daemon-reload")
for _, name := range kept {
if _, statErr := os.Stat(filepath.Join(dataDir, filepath.FromSlash(name))); statErr != nil {
t.Errorf("%s must be left in place", name)
}
}
if infraUpdate.NewFileHelperDetector(filepath.Join(dataDir, update.HelperStatusFileName)).Installed() {
t.Error("can_apply must read false afterwards")
}
again, err := rig.disable.Disable(update.InstallOptions{})
if err != nil || !strings.Contains(again, "not enabled") {
t.Errorf("a second run: message = %q, err = %v", again, err)
}
})
t.Run("disable-browser-updates where it was never enabled exits cleanly", func(t *testing.T) {
rig, _, _, _ := setup(t)
message, err := rig.disable.Disable(update.InstallOptions{})
if err != nil || !strings.Contains(message, "not enabled") {
t.Errorf("message = %q, err = %v", message, err)
}
})
t.Run("disable-browser-updates on a machine without systemd is a no-op", func(t *testing.T) {
rig := newInstallRig()
rig.probe.os = "windows"
message, err := rig.disable.Disable(update.InstallOptions{})
if err != nil || !strings.Contains(message, "not enabled") || len(rig.events) != 0 {
t.Errorf("message = %q, err = %v, events = %v", message, err, rig.events)
}
})
}
func TestRenderServiceUnitEscaping(t *testing.T) {
t.Run("backslashes, quotes, percent and dollar in a value cannot alter the unit", func(t *testing.T) {
unit := update.RenderServiceUnit(update.InstallOptions{
Service: `svc\x`,
Binary: `/opt/a\b"c%d$e/bin`,
DataDir: "/data\nUser=evil",
Port: "80",
})
want := `ExecStart="/opt/a\\b\"c%%d$$e/bin" apply-update --service "svc\\x" --binary "/opt/a\\b\"c%%d$$e/bin" --data-dir "/data\nUser=evil" --port 80`
if !strings.Contains(unit, want) {
t.Errorf("unit:\n%s\nwant line %s", unit, want)
}
if strings.Count(unit, "\nUser=") != 1 {
t.Errorf("an injected directive must not appear:\n%s", unit)
}
})
t.Run("a plain value stays unquoted", func(t *testing.T) {
unit := update.RenderServiceUnit(update.InstallOptions{Service: "renewcms", Binary: "/opt/renewcms/renewcms", DataDir: "/var/lib/renewcms", Port: "8080"})
if !strings.Contains(unit, "ExecStart=/opt/renewcms/renewcms apply-update --service renewcms") {
t.Errorf("unit:\n%s", unit)
}
})
}
+124
View File
@@ -0,0 +1,124 @@
//go:build linux
package test
import (
infraUpdate "RenewCMS/internal/infrastructure/update"
"os"
"path/filepath"
"testing"
)
func TestNoFollowFilesystemOnLinux(t *testing.T) {
files := infraUpdate.NewNoFollowFilesystem()
directory := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("sentinel"), 0o644); err != nil {
t.Fatal(err)
}
link := filepath.Join(directory, "link")
if err := os.Symlink(outside, link); err != nil {
t.Fatal(err)
}
t.Run("lstat reports a symlink without following it", func(t *testing.T) {
info, err := files.Lstat(link)
if err != nil || !info.IsSymlink || info.IsRegular {
t.Errorf("info = %+v, err = %v", info, err)
}
})
t.Run("it never reads, copies, writes or chmods through a symlink", func(t *testing.T) {
if _, err := files.ReadFile(link); err == nil {
t.Error("read followed the link")
}
if err := files.Copy(link, filepath.Join(directory, "copy"), 0o600); err == nil {
t.Error("copy followed the link")
}
if err := files.CreateExclusive(link, []byte("x"), 0o600); err == nil {
t.Error("create followed the link")
}
if err := files.Chmod(link, 0o777); err == nil {
t.Error("chmod followed the link")
}
if content, _ := os.ReadFile(outside); string(content) != "sentinel" {
t.Errorf("the target changed: %q", content)
}
if info, _ := os.Stat(outside); info.Mode().Perm() != 0o644 {
t.Errorf("the target mode changed: %v", info.Mode())
}
})
t.Run("create-exclusive refuses an existing file and sets the mode", func(t *testing.T) {
path := filepath.Join(directory, "exclusive")
if err := files.CreateExclusive(path, []byte("one"), 0o640); err != nil {
t.Fatal(err)
}
if err := files.CreateExclusive(path, []byte("two"), 0o640); err == nil {
t.Error("the second create must fail")
}
if info, _ := os.Stat(path); info.Mode().Perm() != 0o640 {
t.Errorf("mode = %v", info.Mode())
}
})
t.Run("copy, rename, chmod and remove work on regular files", func(t *testing.T) {
source := filepath.Join(directory, "source")
_ = os.WriteFile(source, []byte("payload"), 0o600)
copied := filepath.Join(directory, "copied")
if err := files.Copy(source, copied, 0o755); err != nil {
t.Fatal(err)
}
renamed := filepath.Join(directory, "renamed")
if err := files.Rename(copied, renamed); err != nil {
t.Fatal(err)
}
if err := files.Chmod(renamed, 0o700); err != nil {
t.Fatal(err)
}
content, _ := files.ReadFile(renamed)
info, _ := os.Stat(renamed)
if string(content) != "payload" || info.Mode().Perm() != 0o700 {
t.Errorf("content = %q, mode = %v", content, info.Mode())
}
if err := files.Remove(renamed); err != nil {
t.Error(err)
}
})
t.Run("remove-all unlinks links and never descends into them", func(t *testing.T) {
tree := filepath.Join(directory, "tree")
_ = os.MkdirAll(filepath.Join(tree, "nested"), 0o755)
_ = os.WriteFile(filepath.Join(tree, "nested", "file"), []byte("x"), 0o644)
escape := t.TempDir()
_ = os.WriteFile(filepath.Join(escape, "keep"), []byte("keep"), 0o644)
_ = os.Symlink(escape, filepath.Join(tree, "escape"))
if err := files.RemoveAll(tree); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(tree); err == nil {
t.Error("the tree must be gone")
}
if content, _ := os.ReadFile(filepath.Join(escape, "keep")); string(content) != "keep" {
t.Error("the link target must be intact")
}
if err := files.RemoveAll(tree); err != nil {
t.Errorf("removing a missing path = %v", err)
}
})
t.Run("the temp directory is private", func(t *testing.T) {
temp, err := files.MakePrivateTempDir()
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(temp)
info, _ := os.Stat(temp)
if !info.IsDir() || info.Mode().Perm() != 0o700 {
t.Errorf("mode = %v", info.Mode())
}
})
}
+34
View File
@@ -0,0 +1,34 @@
//go:build !linux
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"errors"
"testing"
)
func TestNoFollowFilesystemStubRefuses(t *testing.T) {
files := infraUpdate.NewNoFollowFilesystem()
_, lstatErr := files.Lstat("x")
_, readErr := files.ReadFile("x")
_, tempErr := files.MakePrivateTempDir()
for name, err := range map[string]error{
"lstat": lstatErr,
"read": readErr,
"tempdir": tempErr,
"create": files.CreateExclusive("x", nil, 0o600),
"copy": files.Copy("x", "y", 0o600),
"rename": files.Rename("x", "y"),
"remove": files.Remove("x"),
"removeAll": files.RemoveAll("x"),
"chmod": files.Chmod("x", 0o600),
} {
if !errors.Is(err, update.ErrHelperUnsupported) {
t.Errorf("%s = %v", name, err)
}
}
}
+913
View File
@@ -0,0 +1,913 @@
package test
import (
"RenewCMS/internal/domain/update"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
"time"
)
const (
helperData = "/var/lib/renewcms"
helperBinary = "/opt/renewcms/renewcms"
helperBinaryDir = "/opt/renewcms"
helperOld = "v2.2.0-beta"
helperSentinel = "sentinel bytes that must never change"
)
var (
helperMarker = helperData + "/" + update.RestartRequestFileName
helperResult = helperData + "/" + update.ResultFileName
helperResultTmp = helperResult + ".tmp"
helperUpdatesDir = helperData + "/" + update.UpdatesDirName
helperStagedDir = helperUpdatesDir + "/" + downloadTag
helperAsset = update.AssetName(downloadTag, update.Platform{OS: "linux", Arch: "amd64"})
)
type fsVerifier struct {
files *fakeHelperFS
key ed25519.PublicKey
events *[]string
}
var _ update.SignatureVerifier = &fsVerifier{}
func (v *fsVerifier) Verify(checksumsPath string, signaturePath string) error {
*v.events = append(*v.events, "verify "+checksumsPath)
checksums, found := v.files.content(checksumsPath)
if !found {
return errors.New("checksums missing")
}
rawSignature, found := v.files.content(signaturePath)
if !found {
return errors.New("signature missing")
}
signature, err := base64.StdEncoding.DecodeString(strings.TrimSpace(rawSignature))
if err != nil || !ed25519.Verify(v.key, []byte(checksums), signature) {
return errors.New("signature mismatch")
}
return nil
}
type helperRig struct {
t *testing.T
fs *fakeHelperFS
running *fakeRunningInfoStore
clock *fakeClock
service *fakeServiceControl
health *fakeHealthChecker
logger *fakeUpdateLogger
events []string
public ed25519.PublicKey
private ed25519.PrivateKey
newBinary string
bootVersions []string
oldInfo update.RunningInfo
usecase *useCases.ApplyRestartRequestUseCase
}
func newHelperRig(t *testing.T) *helperRig {
t.Helper()
public, private, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
rig := &helperRig{
t: t,
fs: newFakeHelperFS(),
running: &fakeRunningInfoStore{},
clock: &fakeClock{now: restartBaseTime},
health: &fakeHealthChecker{healthy: true},
logger: &fakeUpdateLogger{},
public: public,
private: private,
newBinary: "new binary bytes 2.3.0",
bootVersions: []string{downloadTag, helperOld},
oldInfo: update.RunningInfo{Version: helperOld, Pid: 100, StartedAt: restartBaseTime.Add(-time.Hour)},
}
rig.fs.events = &rig.events
rig.fs.putFile(helperBinary, "old")
rig.fs.putDir(helperData)
rig.running.set(rig.oldInfo)
rig.service = &fakeServiceControl{clock: rig.clock, failOn: map[int]error{}, events: &rig.events}
rig.service.onRestart = func(call int) {
version := rig.bootVersions[min(call, len(rig.bootVersions))-1]
rig.running.set(update.RunningInfo{Version: version, Pid: 200 + call, StartedAt: rig.clock.Now().Add(time.Second)})
}
rig.usecase = rig.build()
return rig
}
func (r *helperRig) build() *useCases.ApplyRestartRequestUseCase {
return useCases.NewApplyRestartRequestUseCase(useCases.ApplyRestartRequestDependencies{
Requests: infraUpdate.NewFSRestartRequestStore(r.fs, helperMarker),
Results: infraUpdate.NewFSResultStore(r.fs, helperResult),
Running: r.running,
Files: r.fs,
Service: r.service,
Health: r.health,
Verifier: &fsVerifier{files: r.fs, key: r.public, events: &r.events},
Clock: r.clock,
Logger: r.logger,
Layout: update.HelperLayout{DataDir: helperData, BinaryPath: helperBinary},
Platform: update.Platform{OS: "linux", Arch: "amd64"},
})
}
func (r *helperRig) checksumsFor(binary string) string {
return update.SHA256Hex([]byte(binary)) + " " + helperAsset + "\n"
}
func (r *helperRig) sign(checksums string, key ed25519.PrivateKey) string {
return base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(checksums))) + "\n"
}
func (r *helperRig) stage() {
checksums := r.checksumsFor(r.newBinary)
r.fs.putFile(helperStagedDir+"/"+helperAsset, r.newBinary)
r.fs.putFile(helperStagedDir+"/checksums.txt", checksums)
r.fs.putFile(helperStagedDir+"/checksums.txt.sig", r.sign(checksums, r.private))
}
func (r *helperRig) markUpdate(tag string) {
r.writeMarker(update.RestartRequest{Kind: update.RestartKindUpdate, Tag: tag, RequestedAt: restartBaseTime, RequestedBy: 1})
}
func (r *helperRig) markRestart() {
r.bootVersions = []string{helperOld, helperOld}
r.writeMarker(update.RestartRequest{Kind: update.RestartKindRestart, RequestedAt: restartBaseTime, RequestedBy: 1})
}
func (r *helperRig) writeMarker(request update.RestartRequest) {
content, _ := json.Marshal(request)
r.fs.putFile(helperMarker, string(content))
}
func (r *helperRig) apply() error {
r.t.Helper()
return r.usecase.Apply()
}
func (r *helperRig) result() update.Result {
r.t.Helper()
content, found := r.fs.content(helperResult)
if !found {
r.t.Fatal("update-result.json was not written")
}
var result update.Result
if err := json.Unmarshal([]byte(content), &result); err != nil {
r.t.Fatalf("result is not json: %v", err)
}
return result
}
func (r *helperRig) outcomes() []string {
var outcomes []string
for _, content := range r.fs.history[helperResult] {
var result update.Result
_ = json.Unmarshal([]byte(content), &result)
outcomes = append(outcomes, result.Outcome)
}
return outcomes
}
func (r *helperRig) assertBinary(want string) {
r.t.Helper()
if content, _ := r.fs.content(helperBinary); content != want {
r.t.Errorf("binary = %q, want %q", content, want)
}
}
func (r *helperRig) assertUntouched() {
r.t.Helper()
r.assertBinary("old")
if r.fs.exists(helperBinary + ".previous") {
r.t.Error("no .previous may be written")
}
if r.service.restartCount() != 0 {
r.t.Errorf("restarts = %d, want 0", r.service.restartCount())
}
}
func (r *helperRig) assertOutsideUntouched(sentinelPaths ...string) {
r.t.Helper()
if outside := r.fs.resolvedOutside(helperData, helperBinaryDir, "/tmp"); len(outside) != 0 {
r.t.Errorf("operations reached outside the allowed directories: %v", outside)
}
for _, target := range sentinelPaths {
if content, _ := r.fs.content(target); content != helperSentinel {
r.t.Errorf("%s changed: %q", target, content)
}
}
}
func (r *helperRig) assertCleaned() {
r.t.Helper()
if r.fs.exists(helperStagedDir) {
r.t.Error("the staged directory must be removed")
}
for name := range r.fs.nodes {
if strings.HasPrefix(name, "/tmp/renewcms-update-") {
r.t.Errorf("the temp directory must be removed, found %s", name)
}
}
}
func assertInOrder(t *testing.T, events []string, wants ...string) {
t.Helper()
position := 0
for _, want := range wants {
found := false
for ; position < len(events); position++ {
if strings.HasPrefix(events[position], want) {
found = true
position++
break
}
}
if !found {
t.Fatalf("%q is missing or out of order in:\n%s", want, strings.Join(events, "\n"))
}
}
}
func TestApplyPlainRestart(t *testing.T) {
t.Run("apply-update, a plain restart", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.fs.putFile(helperStagedDir+"/keep", "staged")
if err := rig.apply(); err != nil {
t.Fatal(err)
}
assertInOrder(t, rig.events, "lstat "+helperMarker, "read "+helperMarker, "remove "+helperMarker, "create "+helperResultTmp, "restart", "create "+helperResultTmp)
if got := rig.outcomes(); len(got) != 2 || got[0] != "running" || got[1] != "succeeded" {
t.Errorf("outcomes = %v", got)
}
result := rig.result()
if result.Kind != "restart" || result.FinishedAt == nil || result.FromVersion != helperOld || !result.StartedAt.Equal(restartBaseTime) {
t.Errorf("result = %+v", result)
}
if rig.service.restartCount() != 1 || rig.fs.exists(helperBinary+".previous") {
t.Error("one restart, no write next to the binary")
}
rig.assertBinary("old")
if content, _ := rig.fs.content(helperStagedDir + "/keep"); content != "staged" {
t.Error("the staged files must be untouched")
}
if rig.fs.exists(helperMarker) {
t.Error("the marker must be deleted")
}
})
t.Run("no marker means nothing to do", func(t *testing.T) {
rig := newHelperRig(t)
if err := rig.apply(); err != nil || rig.fs.exists(helperResult) || rig.service.restartCount() != 0 {
t.Errorf("err = %v", err)
}
})
t.Run("a corrupt marker or an unknown kind fails without restarting", func(t *testing.T) {
for name, content := range map[string]string{"corrupt": "{nope", "unknown kind": `{"kind":"reboot","requested_at":"2026-09-30T09:50:00Z"}`} {
t.Run(name, func(t *testing.T) {
rig := newHelperRig(t)
rig.fs.putFile(helperMarker, content)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 0 || rig.fs.exists(helperMarker) {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
})
}
})
}
func TestApplyStaleRunningInfo(t *testing.T) {
staleRows := map[string]struct {
kind string
boots func(rig *helperRig, call int)
}{
"still holds the old pid and old started_at": {"restart", func(rig *helperRig, call int) {}},
"holds the target version but the old pid and an old started_at": {"update", func(rig *helperRig, call int) {
rig.running.set(update.RunningInfo{Version: downloadTag, Pid: rig.oldInfo.Pid, StartedAt: rig.oldInfo.StartedAt})
}},
"is missing": {"restart", func(rig *helperRig, call int) { rig.running.unreadable() }},
"is corrupt": {"restart", func(rig *helperRig, call int) { rig.running.unreadable() }},
"holds the old version with a new pid (for an update)": {"update", func(rig *helperRig, call int) {
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: rig.clock.Now().Add(time.Second)})
}},
}
for name, row := range staleRows {
t.Run("A stale running file never counts as success: "+name, func(t *testing.T) {
rig := newHelperRig(t)
if row.kind == "update" {
rig.stage()
rig.markUpdate(downloadTag)
} else {
rig.markRestart()
}
rig.service.onRestart = func(call int) {
if call == 1 {
row.boots(rig, call)
return
}
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 500, StartedAt: rig.clock.Now().Add(time.Second)})
}
err := rig.apply()
result := rig.result()
first := rig.service.restarts[0]
if row.kind == "restart" {
if err == nil || result.Outcome != "failed" || rig.service.restartCount() != 1 {
t.Errorf("err = %v, result = %+v", err, result)
}
if rig.clock.Now().Sub(first) < update.RestartWindow {
t.Errorf("gave up after %v", rig.clock.Now().Sub(first))
}
return
}
if result.Outcome != "rolled_back" || rig.service.restartCount() != 2 {
t.Fatalf("result = %+v, restarts = %d", result, rig.service.restartCount())
}
if rig.service.restarts[1].Sub(first) < update.RestartWindow {
t.Errorf("rolled back after %v, before the 60 s window", rig.service.restarts[1].Sub(first))
}
})
}
t.Run("a file with the same pid but a later started_at counts as new", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.service.onRestart = func(int) {
rig.running.set(update.RunningInfo{Version: helperOld, Pid: rig.oldInfo.Pid, StartedAt: rig.clock.Now().Add(time.Second)})
}
if err := rig.apply(); err != nil || rig.result().Outcome != "succeeded" || rig.clock.sleeps != 0 {
t.Errorf("err = %v, outcome = %v, sleeps = %d", err, rig.result().Outcome, rig.clock.sleeps)
}
})
t.Run("success needs a healthy answer too", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.health.healthy = false
err := rig.apply()
if err == nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
})
}
func TestApplyUpdate(t *testing.T) {
t.Run("apply-update, an update, happy path", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
temp := "/tmp/renewcms-update-1"
if err := rig.apply(); err != nil {
t.Fatal(err)
}
assertInOrder(t, rig.events,
"read "+helperMarker, "remove "+helperMarker, "create "+helperResultTmp,
"lstat "+helperStagedDir+"/"+helperAsset, "tempdir",
"copy "+helperStagedDir+"/"+helperAsset, "copy "+helperStagedDir+"/checksums.txt", "copy "+helperStagedDir+"/checksums.txt.sig",
"verify "+temp+"/checksums.txt", "read "+temp+"/checksums.txt", "read "+temp+"/"+helperAsset,
"copy "+helperBinary, "rename "+helperBinary+".previous.tmp",
"copy "+temp+"/"+helperAsset, "read "+helperBinary+".new", "chmod "+helperBinary+".new", "rename "+helperBinary+".new",
"restart", "create "+helperResultTmp, "removeall "+temp, "removeall "+helperStagedDir)
rig.assertBinary(rig.newBinary)
if previous, _ := rig.fs.content(helperBinary + ".previous"); previous != "old" {
t.Errorf(".previous = %q", previous)
}
if rig.fs.mode(helperBinary) != 0o755 || rig.fs.mode(temp) != 0 {
t.Errorf("binary mode = %o, temp directory must be gone", rig.fs.mode(helperBinary))
}
if rig.clock.sleeps != 0 || rig.service.restartCount() != 1 {
t.Errorf("success must be declared on the first matching poll: sleeps = %d", rig.clock.sleeps)
}
result := rig.result()
if result.Outcome != "succeeded" || result.Kind != "update" || result.Tag != downloadTag || result.FromVersion != helperOld {
t.Errorf("result = %+v", result)
}
rig.assertCleaned()
rig.assertOutsideUntouched()
if rig.fs.exists(helperBinary+".new") || rig.fs.exists(helperBinary+".previous.tmp") {
t.Error("no temp file may be left next to the binary")
}
})
t.Run("the verified copy lives in a private temp directory", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.after("read", 4, func(f *fakeHelperFS) {
if f.mode("/tmp/renewcms-update-1") != 0o700 || f.nodes["/tmp/renewcms-update-1"].owner != "root" {
t.Errorf("temp directory mode = %o owner = %s", f.mode("/tmp/renewcms-update-1"), f.nodes["/tmp/renewcms-update-1"].owner)
}
})
_ = rig.apply()
})
t.Run("apply-update refuses an untrusted staged binary", func(t *testing.T) {
faults := map[string]struct {
mutate func(*helperRig)
reason string
}{
"the binary was changed after the job verified it": {func(r *helperRig) { r.fs.putFile(helperStagedDir+"/"+helperAsset, "tampered") }, "sha-256 mismatch"},
"checksums.txt was changed": {func(r *helperRig) {
r.fs.putFile(helperStagedDir+"/checksums.txt", r.checksumsFor("tampered"))
}, "signature"},
"the signature is from a different key": {func(r *helperRig) {
_, other, _ := ed25519.GenerateKey(rand.Reader)
r.fs.putFile(helperStagedDir+"/checksums.txt.sig", r.sign(r.checksumsFor(r.newBinary), other))
}, "signature"},
"the signature is missing": {func(r *helperRig) { delete(r.fs.nodes, helperStagedDir+"/checksums.txt.sig") }, "checksums.txt.sig"},
"checksums.txt has no line for the asset": {func(r *helperRig) {
checksums := update.SHA256Hex([]byte("x")) + " something_else\n"
r.fs.putFile(helperStagedDir+"/checksums.txt", checksums)
r.fs.putFile(helperStagedDir+"/checksums.txt.sig", r.sign(checksums, r.private))
}, "no line"},
"the staged binary is missing": {func(r *helperRig) { delete(r.fs.nodes, helperStagedDir+"/"+helperAsset) }, helperAsset},
}
for name, fault := range faults {
t.Run(name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
fault.mutate(rig)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
rig.assertCleaned()
if !strings.Contains(rig.logger.text(), fault.reason) {
t.Errorf("the log must carry the reason %q: %s", fault.reason, rig.logger.text())
}
if raw, _ := rig.fs.content(helperResult); strings.Contains(raw, "mismatch") || strings.Contains(raw, helperData) {
t.Errorf("the technical reason must stay out of the result file: %s", raw)
}
})
}
})
}
func TestApplyRefusesSymlinks(t *testing.T) {
const outside = "/outside"
type row struct {
setup func(*helperRig)
link string
keep []string
}
rows := map[string]row{
"the marker itself is a symlink": {func(r *helperRig) {
r.fs.putFile(outside+"/secret", helperSentinel)
r.fs.putSymlink(helperMarker, outside+"/secret")
}, helperMarker, []string{outside + "/secret"}},
"<data>/updates is a symlink to another directory": {func(r *helperRig) {
r.fs.putDir(outside + "/updates/" + downloadTag)
r.fs.putFile(outside+"/updates/"+downloadTag+"/"+helperAsset, helperSentinel)
r.fs.putFile(outside+"/updates/"+downloadTag+"/checksums.txt", helperSentinel)
r.fs.putFile(outside+"/updates/"+downloadTag+"/checksums.txt.sig", helperSentinel)
r.forget(helperUpdatesDir)
r.fs.putSymlink(helperUpdatesDir, outside+"/updates")
}, helperUpdatesDir, []string{outside + "/updates/" + downloadTag + "/" + helperAsset}},
"<data>/updates/<tag> is a symlink to another directory": {func(r *helperRig) {
r.fs.putFile(outside+"/tag/"+helperAsset, helperSentinel)
r.fs.putFile(outside+"/tag/checksums.txt", helperSentinel)
r.fs.putFile(outside+"/tag/checksums.txt.sig", helperSentinel)
r.forget(helperStagedDir)
r.fs.putSymlink(helperStagedDir, outside+"/tag")
}, helperStagedDir, []string{outside + "/tag/" + helperAsset, outside + "/tag/checksums.txt"}},
"the staged binary is a symlink to /usr/bin/something": {func(r *helperRig) {
r.fs.putFile("/usr/bin/something", helperSentinel)
delete(r.fs.nodes, helperStagedDir+"/"+helperAsset)
r.fs.putSymlink(helperStagedDir+"/"+helperAsset, "/usr/bin/something")
}, helperStagedDir + "/" + helperAsset, []string{"/usr/bin/something"}},
"checksums.txt is a symlink": {func(r *helperRig) {
r.fs.putFile(outside+"/c", helperSentinel)
delete(r.fs.nodes, helperStagedDir+"/checksums.txt")
r.fs.putSymlink(helperStagedDir+"/checksums.txt", outside+"/c")
}, helperStagedDir + "/checksums.txt", []string{outside + "/c"}},
"checksums.txt.sig is a symlink": {func(r *helperRig) {
r.fs.putFile(outside+"/s", helperSentinel)
delete(r.fs.nodes, helperStagedDir+"/checksums.txt.sig")
r.fs.putSymlink(helperStagedDir+"/checksums.txt.sig", outside+"/s")
}, helperStagedDir + "/checksums.txt.sig", []string{outside + "/s"}},
"a symlink is placed at the result path": {func(r *helperRig) {
r.fs.putFile(outside+"/result-target", helperSentinel)
r.fs.putSymlink(helperResult, outside+"/result-target")
}, helperResult, []string{outside + "/result-target"}},
"a symlink is placed at a temp file name": {func(r *helperRig) {
r.fs.putFile(outside+"/tmp-target", helperSentinel)
r.fs.putSymlink(helperResultTmp, outside+"/tmp-target")
}, helperResultTmp, []string{outside + "/tmp-target"}},
}
for name, tc := range rows {
t.Run("apply-update refuses symlinks: "+name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
tc.setup(rig)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
rig.assertOutsideUntouched(tc.keep...)
if !strings.Contains(rig.logger.text(), tc.link) {
t.Errorf("the log must name %s: %s", tc.link, rig.logger.text())
}
if node := rig.fs.nodes[helperResult]; node == nil || node.kind != "file" {
t.Error("the result must be a regular file written next to the link, not through it")
}
if len(rig.fs.touched("read", "copy")) > 0 {
for _, operation := range rig.fs.touched("read", "copy") {
if strings.HasPrefix(operation.resolved, outside) || strings.HasPrefix(operation.resolved, "/usr") {
t.Errorf("read through a link: %+v", operation)
}
}
}
})
}
t.Run("A symlinked marker is removed, not followed", func(t *testing.T) {
rig := newHelperRig(t)
rig.fs.putFile(outside+"/secret", helperSentinel)
rig.fs.putSymlink(helperMarker, outside+"/secret")
err := rig.apply()
if err != nil || rig.fs.exists(helperMarker) || rig.result().Outcome != "failed" {
t.Errorf("err = %v, marker present = %v", err, rig.fs.exists(helperMarker))
}
if content, _ := rig.fs.content(outside + "/secret"); content != helperSentinel {
t.Error("the target must be intact")
}
})
t.Run("a link found in the staged directory is unlinked, not followed, when cleaning", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.putFile(outside+"/tag/"+helperAsset, helperSentinel)
rig.forget(helperStagedDir)
rig.fs.putSymlink(helperStagedDir, outside+"/tag")
_ = rig.apply()
if rig.fs.exists(helperStagedDir) {
t.Error("the link itself may be removed")
}
if content, _ := rig.fs.content(outside + "/tag/" + helperAsset); content != helperSentinel {
t.Error("the target must be intact")
}
})
}
func (r *helperRig) forget(prefix string) {
for name := range r.fs.nodes {
if name == prefix || strings.HasPrefix(name, prefix+"/") {
delete(r.fs.nodes, name)
}
}
}
func TestApplySwapIsRaceFree(t *testing.T) {
t.Run("A file swapped after verification is not installed", func(t *testing.T) {
swapAfter := map[string]struct {
operation string
call int
}{
"the copy step": {"copy", 3},
"the signature check": {"read", 2},
"the hash check": {"read", 3},
"the .previous copy": {"copy", 4},
"the final copy and install": {"copy", 5},
}
for name, point := range swapAfter {
t.Run(name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.after(point.operation, point.call, func(f *fakeHelperFS) {
f.putFile(helperStagedDir+"/"+helperAsset, "attacker bytes")
})
err := rig.apply()
if err != nil {
t.Fatal(err)
}
rig.assertBinary(rig.newBinary)
if rig.result().Outcome != "succeeded" {
t.Errorf("result = %+v", rig.result())
}
})
}
})
t.Run("a swap of the temp file in the binary's directory before the rename is caught by the re-hash", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.after("copy", 5, func(f *fakeHelperFS) {
f.putFile(helperBinary+".new", "attacker bytes in the temp file")
})
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertBinary("old")
if rig.service.restartCount() != 0 || rig.fs.exists(helperBinary+".new") {
t.Error("no restart and the temp file must be removed")
}
})
}
func TestApplyResultWrites(t *testing.T) {
t.Run("Result and temp files are written without following links", func(t *testing.T) {
files := newFakeHelperFS()
files.putFile("/outside/a", helperSentinel)
files.putFile("/outside/b", helperSentinel)
files.putDir(helperData)
files.putSymlink(helperResult, "/outside/a")
files.putSymlink(helperResultTmp, "/outside/b")
store := infraUpdate.NewFSResultStore(files, helperResult)
err := store.Write(update.Result{Outcome: update.OutcomeFailed, Kind: "update", StartedAt: restartBaseTime, FinishedAt: finishedAt(restartBaseTime)})
if err != nil {
t.Fatal(err)
}
if a, _ := files.content("/outside/a"); a != helperSentinel {
t.Error("the result link target must be untouched")
}
if b, _ := files.content("/outside/b"); b != helperSentinel {
t.Error("the temp link target must be untouched")
}
if node := files.nodes[helperResult]; node == nil || node.kind != "file" || node.mode != 0o644 {
t.Errorf("result node = %+v", node)
}
if got, found := store.Read(); !found || got.Outcome != update.OutcomeFailed {
t.Errorf("read back = %+v, %v", got, found)
}
assertInOrder(t, files.eventsFrom(), "lstat", "remove", "create", "rename")
})
}
func (f *fakeHelperFS) eventsFrom() []string {
names := make([]string, 0, len(f.ops))
for _, operation := range f.ops {
names = append(names, operation.name+" "+operation.path)
}
return names
}
func TestApplyDistrustsTheMarker(t *testing.T) {
tags := map[string]string{
"../../etc/passwd": "../../etc/passwd",
"v2.3.0-beta/../../x": "v2.3.0-beta/../../x",
"an empty string": "",
"v2.1.0-beta (older than the running)": "v2.1.0-beta",
"not-a-version": "not-a-version",
"the running version itself": helperOld,
}
for name, tag := range tags {
t.Run("apply-update distrusts the marker: "+name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.fs.putFile("/var/lib/renewcms/etc/passwd", helperSentinel)
rig.markUpdate(tag)
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
rig.assertOutsideUntouched()
for _, operation := range rig.fs.ops {
if strings.Contains(operation.path, "..") || strings.Contains(operation.path, "passwd") || strings.HasPrefix(operation.path, helperUpdatesDir) {
t.Errorf("a distrusted tag reached the filesystem: %+v", operation)
}
}
if !rig.fs.exists(helperStagedDir + "/" + helperAsset) {
t.Error("staged files of another tag must be left alone")
}
})
}
}
func TestApplyRollsBack(t *testing.T) {
faults := map[string]func(*helperRig){
"/healthz never answers within 60 s": func(r *helperRig) {
r.health.healthy = false
r.service.onRestart = func(call int) {
if call == 2 {
r.health.healthy = true
}
r.running.set(update.RunningInfo{Version: r.bootVersions[call-1], Pid: 200 + call, StartedAt: r.clock.Now().Add(time.Second)})
}
},
"/healthz answers but update-running.json stays old": func(r *helperRig) {
r.service.onRestart = func(call int) {
if call == 2 {
r.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: r.clock.Now().Add(time.Second)})
}
}
},
"update-running.json holds another version": func(r *helperRig) {
r.service.onRestart = func(call int) {
version := "v9.9.9"
if call == 2 {
version = helperOld
}
r.running.set(update.RunningInfo{Version: version, Pid: 200 + call, StartedAt: r.clock.Now().Add(time.Second)})
}
},
"the service restart command fails": func(r *helperRig) {
r.service.failOn[1] = errors.New("systemctl restart renewcms: exit status 1")
},
}
for name, fault := range faults {
t.Run("apply-update rolls back when the new version does not come up: "+name, func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
fault(rig)
err := rig.apply()
if err != nil {
t.Fatalf("a rollback that worked is not an error: %v", err)
}
rig.assertBinary("old")
if rig.service.restartCount() != 2 {
t.Fatalf("restarts = %d", rig.service.restartCount())
}
if name != "the service restart command fails" && rig.service.restarts[1].Sub(rig.service.restarts[0]) < update.RestartWindow {
t.Errorf("rolled back after %v", rig.service.restarts[1].Sub(rig.service.restarts[0]))
}
assertInOrder(t, rig.events, "rename "+helperBinary+".new", "restart", "copy "+helperBinary+".previous", "rename "+helperBinary+".restore", "restart")
result := rig.result()
if result.Outcome != "rolled_back" || result.FromVersion != helperOld || result.Tag != downloadTag || result.FinishedAt == nil {
t.Errorf("result = %+v", result)
}
rig.assertCleaned()
if rig.fs.exists(helperBinary + ".restore") {
t.Error("no restore temp file may be left")
}
})
}
}
func TestApplySwapFailures(t *testing.T) {
t.Run("copying to .previous fails", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.failNth("copy", 4, errors.New("no space left on device"))
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertUntouched()
})
t.Run("the atomic install fails", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.fs.failNth("rename", 3, errors.New("text file busy"))
err := rig.apply()
if err != nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 0 {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
rig.assertBinary("old")
if previous, _ := rig.fs.content(helperBinary + ".previous"); previous != "old" {
t.Errorf(".previous = %q", previous)
}
if rig.fs.exists(helperBinary + ".new") {
t.Error("the temp file must be removed")
}
})
t.Run("the rollback restore itself fails", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.service.failOn[1] = errors.New("restart failed")
rig.fs.failNth("copy", 6, errors.New("input/output error"))
err := rig.apply()
if err == nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 1 {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
if !strings.Contains(rig.logger.text(), "the site may be down") {
t.Errorf("the failure must be logged loudly: %s", rig.logger.text())
}
})
t.Run("the old version never returns after rollback", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.service.onRestart = func(call int) {
if call == 1 {
rig.running.set(update.RunningInfo{Version: "v9.9.9", Pid: 201, StartedAt: rig.clock.Now().Add(time.Second)})
}
}
err := rig.apply()
if err == nil || rig.result().Outcome != "failed" || rig.service.restartCount() != 2 {
t.Errorf("err = %v, result = %+v", err, rig.result())
}
})
}
func TestApplyAlwaysWritesAResult(t *testing.T) {
t.Run("a panic in the helper still leaves a failed result", func(t *testing.T) {
rig := newHelperRig(t)
rig.stage()
rig.markUpdate(downloadTag)
rig.service.panicOn = 1
err := rig.apply()
result := rig.result()
if err == nil || result.Outcome != "failed" || result.FinishedAt == nil || result.Kind != "update" || result.Tag != downloadTag || result.FromVersion != helperOld {
t.Errorf("err = %v, result = %+v", err, result)
}
assertInOrder(t, rig.events, "create "+helperResultTmp, "rename "+helperResultTmp, "restart", "create "+helperResultTmp)
})
t.Run("a running record exists before the first service call and every write is a temp file plus a rename", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
var seenBeforeRestart []string
rig.service.onRestart = func(call int) {
seenBeforeRestart = append([]string(nil), rig.outcomes()...)
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: rig.clock.Now().Add(time.Second)})
}
_ = rig.apply()
if len(seenBeforeRestart) != 1 || seenBeforeRestart[0] != "running" {
t.Errorf("outcomes before the restart = %v", seenBeforeRestart)
}
creates := rig.fs.operationCount("create")
renames := len(rig.fs.history[helperResult])
if creates != renames || creates != 2 {
t.Errorf("creates = %d, renames = %d", creates, renames)
}
})
t.Run("timestamps come from the clock and finished is not before started", func(t *testing.T) {
rig := newHelperRig(t)
rig.markRestart()
rig.clock.onSleep = func(sleeps int) {
if sleeps == 3 {
rig.running.set(update.RunningInfo{Version: helperOld, Pid: 300, StartedAt: rig.clock.Now().Add(time.Second)})
}
}
rig.service.onRestart = func(int) {}
if err := rig.apply(); err != nil {
t.Fatal(err)
}
result := rig.result()
if !result.StartedAt.Equal(restartBaseTime) || result.FinishedAt == nil || !result.FinishedAt.Equal(restartBaseTime.Add(3*time.Second)) || result.FinishedAt.Before(result.StartedAt) {
t.Errorf("result = %+v", result)
}
})
}
+5 -13
View File
@@ -28,7 +28,7 @@ const (
updateRunning = "v2.2.0-beta"
)
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "latest", "notes", "progress", "release_url", "started_at", "state"}
var updateStatusKeys = []string{"available", "can_apply", "checked_at", "current", "error", "finished_at", "latest", "notes", "progress", "release_url", "started_at", "state"}
type updateHarness struct {
router http.Handler
@@ -63,7 +63,7 @@ func newUpdateHarness(t *testing.T, running string) *updateHarness {
container := &api.UseCases{
Token: token,
GetUserUseCase: useCases.NewGetUserUseCase(users),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
GetUpdateStatusUseCase: newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, running),
CheckForUpdateUseCase: useCases.NewCheckForUpdateUseCase(feed, store, running, now),
}
return &updateHarness{router: api.NewSystemRouter(container), token: token, feed: feed, store: store}
@@ -137,8 +137,8 @@ func TestUpdateStatusHTTP(t *testing.T) {
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)
if body["progress"] != nil || body["error"] != nil || body["started_at"] != nil || body["finished_at"] != nil {
t.Errorf("progress, error, started_at and finished_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" {
@@ -159,7 +159,7 @@ func TestUpdateStatusHTTP(t *testing.T) {
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"} {
for _, key := range []string{"release_url", "notes", "checked_at", "progress", "error", "started_at", "finished_at"} {
if body[key] != nil {
t.Errorf("%s = %v, want null", key, body[key])
}
@@ -313,12 +313,4 @@ func TestUpdateCheckHTTP(t *testing.T) {
}
})
t.Run("restart is not mounted", func(t *testing.T) {
h := newUpdateHarness(t, updateRunning)
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)
}
}
})
}
+592
View File
@@ -0,0 +1,592 @@
package test
import (
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"io/fs"
"path"
"sort"
"strings"
"sync"
"time"
)
func newUpdateStatusUseCase(store update.StatusStore, jobs update.JobStore, helper update.HelperDetector, running string) *useCases.GetUpdateStatusUseCase {
return useCases.NewGetUpdateStatusUseCase(store, jobs, helper, &fakeResultStore{}, &fakeRestartRequestStore{}, running, time.Now)
}
type fakeRestartRequestStore struct {
mu sync.Mutex
request update.RestartRequest
present bool
corrupt bool
writeErr error
writes int
deletes int
}
var _ update.RestartRequestStore = &fakeRestartRequestStore{}
func (f *fakeRestartRequestStore) Write(request update.RestartRequest) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.writeErr != nil {
return f.writeErr
}
f.request = request
f.present = true
f.writes++
return nil
}
func (f *fakeRestartRequestStore) Read() (update.RestartRequest, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.corrupt {
return update.RestartRequest{}, errors.New("corrupt marker")
}
if !f.present {
return update.RestartRequest{}, update.ErrNoRestartRequest
}
return f.request, nil
}
func (f *fakeRestartRequestStore) Delete() error {
f.mu.Lock()
defer f.mu.Unlock()
f.present = false
f.deletes++
return nil
}
type fakeResultStore struct {
mu sync.Mutex
result update.Result
found bool
writeErr error
writes []update.Result
deletes int
}
var _ update.ResultStore = &fakeResultStore{}
func (f *fakeResultStore) Read() (update.Result, bool) {
f.mu.Lock()
defer f.mu.Unlock()
return f.result, f.found
}
func (f *fakeResultStore) Write(result update.Result) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.writeErr != nil {
return f.writeErr
}
f.result = result
f.found = true
f.writes = append(f.writes, result)
return nil
}
func (f *fakeResultStore) Delete() error {
f.mu.Lock()
defer f.mu.Unlock()
f.found = false
f.result = update.Result{}
f.deletes++
return nil
}
type fakeRunningInfoStore struct {
mu sync.Mutex
info update.RunningInfo
found bool
writeErr error
writes []update.RunningInfo
}
var _ update.RunningInfoStore = &fakeRunningInfoStore{}
func (f *fakeRunningInfoStore) Write(info update.RunningInfo) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.writeErr != nil {
return f.writeErr
}
f.info = info
f.found = true
f.writes = append(f.writes, info)
return nil
}
func (f *fakeRunningInfoStore) Read() (update.RunningInfo, bool) {
f.mu.Lock()
defer f.mu.Unlock()
return f.info, f.found
}
func (f *fakeRunningInfoStore) set(info update.RunningInfo) {
f.mu.Lock()
defer f.mu.Unlock()
f.info = info
f.found = true
}
func (f *fakeRunningInfoStore) unreadable() {
f.mu.Lock()
defer f.mu.Unlock()
f.info = update.RunningInfo{}
f.found = false
}
type fakeClock struct {
mu sync.Mutex
now time.Time
sleeps int
onSleep func(sleeps int)
}
var _ update.Clock = &fakeClock{}
func (f *fakeClock) Now() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.now
}
func (f *fakeClock) Sleep(duration time.Duration) {
f.mu.Lock()
f.now = f.now.Add(duration)
f.sleeps++
sleeps, hook := f.sleeps, f.onSleep
f.mu.Unlock()
if hook != nil {
hook(sleeps)
}
}
func (f *fakeClock) advance(duration time.Duration) {
f.mu.Lock()
defer f.mu.Unlock()
f.now = f.now.Add(duration)
}
type fakeServiceControl struct {
mu sync.Mutex
restarts []time.Time
clock *fakeClock
failOn map[int]error
panicOn int
onRestart func(call int)
events *[]string
}
var _ update.ServiceControl = &fakeServiceControl{}
func (f *fakeServiceControl) Restart() error {
f.mu.Lock()
f.restarts = append(f.restarts, f.clock.Now())
call := len(f.restarts)
failure := f.failOn[call]
shouldPanic := f.panicOn == call
hook := f.onRestart
f.mu.Unlock()
if f.events != nil {
*f.events = append(*f.events, "restart")
}
if shouldPanic {
panic("service control exploded")
}
if failure != nil {
return failure
}
if hook != nil {
hook(call)
}
return nil
}
func (f *fakeServiceControl) Status() (bool, error) {
return true, nil
}
func (f *fakeServiceControl) restartCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.restarts)
}
type fakeHealthChecker struct {
mu sync.Mutex
healthy bool
asked int
}
var _ update.HealthChecker = &fakeHealthChecker{}
func (f *fakeHealthChecker) Healthy() bool {
f.mu.Lock()
defer f.mu.Unlock()
f.asked++
return f.healthy
}
type fsNode struct {
kind string
content []byte
mode uint32
target string
owner string
}
type fsOperation struct {
name string
path string
resolved string
}
var errFakeLoop = errors.New("too many levels of symbolic links")
type fakeHelperFS struct {
mu sync.Mutex
nodes map[string]*fsNode
ops []fsOperation
counts map[string]int
failures map[string]map[int]error
hooks map[string]map[int]func(*fakeHelperFS)
tempDirs int
events *[]string
history map[string][]string
}
var _ update.HelperFilesystem = &fakeHelperFS{}
func newFakeHelperFS() *fakeHelperFS {
f := &fakeHelperFS{
nodes: map[string]*fsNode{"/": {kind: "dir", mode: 0o755, owner: "root"}},
counts: map[string]int{},
failures: map[string]map[int]error{},
hooks: map[string]map[int]func(*fakeHelperFS){},
history: map[string][]string{},
}
return f
}
func (f *fakeHelperFS) failNth(operation string, call int, err error) {
if f.failures[operation] == nil {
f.failures[operation] = map[int]error{}
}
f.failures[operation][call] = err
}
func (f *fakeHelperFS) after(operation string, call int, mutate func(*fakeHelperFS)) {
if f.hooks[operation] == nil {
f.hooks[operation] = map[int]func(*fakeHelperFS){}
}
f.hooks[operation][call] = mutate
}
func (f *fakeHelperFS) putDir(target string) {
target = path.Clean(target)
for current := target; current != "/"; current = path.Dir(current) {
if _, found := f.nodes[current]; !found {
f.nodes[current] = &fsNode{kind: "dir", mode: 0o755, owner: "app"}
}
}
}
func (f *fakeHelperFS) putFile(target string, content string) {
target = path.Clean(target)
f.putDir(path.Dir(target))
f.nodes[target] = &fsNode{kind: "file", content: []byte(content), mode: 0o644, owner: "app"}
}
func (f *fakeHelperFS) putSymlink(target string, destination string) {
target = path.Clean(target)
f.putDir(path.Dir(target))
f.nodes[target] = &fsNode{kind: "symlink", target: destination, owner: "app"}
}
func (f *fakeHelperFS) content(target string) (string, bool) {
node, found := f.nodes[path.Clean(target)]
if !found || node.kind != "file" {
return "", false
}
return string(node.content), true
}
func (f *fakeHelperFS) exists(target string) bool {
_, found := f.nodes[path.Clean(target)]
return found
}
func (f *fakeHelperFS) mode(target string) uint32 {
if node, found := f.nodes[path.Clean(target)]; found {
return node.mode
}
return 0
}
func (f *fakeHelperFS) resolve(target string, followFinal bool) (string, error) {
return f.resolveDepth(path.Clean(target), followFinal, 0)
}
func (f *fakeHelperFS) resolveDepth(target string, followFinal bool, depth int) (string, error) {
if depth > 8 {
return "", errFakeLoop
}
parts := strings.Split(strings.Trim(target, "/"), "/")
current := "/"
for index, part := range parts {
if part == "" {
continue
}
next := path.Join(current, part)
node, found := f.nodes[next]
last := index == len(parts)-1
if found && node.kind == "symlink" && (!last || followFinal) {
resolved, err := f.resolveDepth(node.target, true, depth+1)
if err != nil {
return "", err
}
current = resolved
continue
}
current = next
}
return current, nil
}
func (f *fakeHelperFS) begin(operation string, target string, followFinal bool) (string, error) {
f.counts[operation]++
resolved, err := f.resolve(target, followFinal)
f.ops = append(f.ops, fsOperation{name: operation, path: target, resolved: resolved})
if f.events != nil {
*f.events = append(*f.events, operation+" "+target)
}
if failure := f.failures[operation][f.counts[operation]]; failure != nil {
return resolved, failure
}
return resolved, err
}
func (f *fakeHelperFS) end(operation string) {
if hook := f.hooks[operation][f.counts[operation]]; hook != nil {
hook(f)
}
}
func missing(operation string, target string) error {
return &fs.PathError{Op: operation, Path: target, Err: fs.ErrNotExist}
}
func (f *fakeHelperFS) Lstat(target string) (update.FileInfo, error) {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("lstat")
resolved, err := f.begin("lstat", target, false)
if err != nil {
return update.FileInfo{}, err
}
node, found := f.nodes[resolved]
if !found {
return update.FileInfo{}, missing("lstat", target)
}
return update.FileInfo{IsSymlink: node.kind == "symlink", IsDir: node.kind == "dir", IsRegular: node.kind == "file"}, nil
}
func (f *fakeHelperFS) ReadFile(target string) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("read")
resolved, err := f.begin("read", target, false)
if err != nil {
return nil, err
}
node, found := f.nodes[resolved]
if !found {
return nil, missing("read", target)
}
if node.kind == "symlink" {
return nil, errFakeLoop
}
if node.kind != "file" {
return nil, errors.New("is a directory")
}
return append([]byte(nil), node.content...), nil
}
func (f *fakeHelperFS) createNode(operation string, target string, content []byte, mode uint32) error {
resolved, err := f.begin(operation, target, false)
if err != nil {
return err
}
if _, found := f.nodes[resolved]; found {
return &fs.PathError{Op: operation, Path: target, Err: fs.ErrExist}
}
parent, found := f.nodes[path.Dir(resolved)]
if !found || parent.kind != "dir" {
return missing(operation, target)
}
f.nodes[resolved] = &fsNode{kind: "file", content: content, mode: mode, owner: "root"}
return nil
}
func (f *fakeHelperFS) CreateExclusive(target string, content []byte, mode uint32) error {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("create")
return f.createNode("create", target, append([]byte(nil), content...), mode)
}
func (f *fakeHelperFS) Copy(source string, destination string, mode uint32) error {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("copy")
resolved, err := f.begin("copy", source, false)
if err != nil {
return err
}
node, found := f.nodes[resolved]
if !found {
return missing("copy", source)
}
if node.kind == "symlink" {
return errFakeLoop
}
if node.kind != "file" {
return errors.New("not a regular file")
}
f.ops = append(f.ops, fsOperation{name: "copy-to", path: destination})
return f.createNode("copy-to", destination, append([]byte(nil), node.content...), mode)
}
func (f *fakeHelperFS) Rename(oldPath string, newPath string) error {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("rename")
from, err := f.begin("rename", oldPath, false)
if err != nil {
return err
}
to, err := f.resolve(newPath, false)
if err != nil {
return err
}
node, found := f.nodes[from]
if !found {
return missing("rename", oldPath)
}
if existing, found := f.nodes[to]; found && existing.kind == "dir" {
return errors.New("is a directory")
}
f.ops = append(f.ops, fsOperation{name: "rename-to", path: newPath, resolved: to})
f.nodes[to] = node
delete(f.nodes, from)
f.history[to] = append(f.history[to], string(node.content))
return nil
}
func (f *fakeHelperFS) Remove(target string) error {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("remove")
resolved, err := f.begin("remove", target, false)
if err != nil {
return err
}
if _, found := f.nodes[resolved]; !found {
return missing("remove", target)
}
delete(f.nodes, resolved)
return nil
}
func (f *fakeHelperFS) RemoveAll(target string) error {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("removeall")
resolved, err := f.begin("removeall", target, false)
if err != nil {
return err
}
prefix := resolved + "/"
for name := range f.nodes {
if name == resolved || strings.HasPrefix(name, prefix) {
delete(f.nodes, name)
}
}
return nil
}
func (f *fakeHelperFS) Chmod(target string, mode uint32) error {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("chmod")
resolved, err := f.begin("chmod", target, false)
if err != nil {
return err
}
node, found := f.nodes[resolved]
if !found {
return missing("chmod", target)
}
if node.kind == "symlink" {
return errFakeLoop
}
node.mode = mode
return nil
}
func (f *fakeHelperFS) MakePrivateTempDir() (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
defer f.end("tempdir")
f.tempDirs++
directory := "/tmp/renewcms-update-" + string(rune('0'+f.tempDirs))
if _, err := f.begin("tempdir", directory, false); err != nil {
return "", err
}
f.nodes["/tmp"] = &fsNode{kind: "dir", mode: 0o1777, owner: "root"}
f.nodes[directory] = &fsNode{kind: "dir", mode: 0o700, owner: "root"}
return directory, nil
}
func (f *fakeHelperFS) touched(operations ...string) []fsOperation {
wanted := map[string]bool{}
for _, name := range operations {
wanted[name] = true
}
var touched []fsOperation
for _, operation := range f.ops {
if wanted[operation.name] {
touched = append(touched, operation)
}
}
return touched
}
func (f *fakeHelperFS) resolvedOutside(allowed ...string) []string {
var outside []string
for _, operation := range f.ops {
if operation.resolved == "" {
continue
}
inside := false
for _, directory := range allowed {
if operation.resolved == directory || strings.HasPrefix(operation.resolved, directory+"/") {
inside = true
}
}
if !inside {
outside = append(outside, operation.name+" "+operation.resolved)
}
}
sort.Strings(outside)
return outside
}
func (f *fakeHelperFS) operationCount(name string) int {
return f.counts[name]
}
+623
View File
@@ -0,0 +1,623 @@
package test
import (
"RenewCMS/api"
"RenewCMS/api/pkg/apperr"
validator "RenewCMS/api/pkg/validator"
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/config"
infraUpdate "RenewCMS/internal/infrastructure/update"
"RenewCMS/internal/infrastructure/useCases"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/jwtauth/v5"
)
const restartPath = "/update/restart"
type restartHTTP struct {
router http.Handler
token *jwtauth.JWTAuth
dataDir string
markerPath string
resultPath string
store *fakeUpdateStore
jobs *fakeJobStore
helper *fakeHelperDetector
clock *fakeClock
markers update.RestartRequestStore
resultFiles update.ResultStore
}
func newRestartHTTP(t *testing.T) *restartHTTP {
t.Helper()
validator.InitValidator()
dataDir := t.TempDir()
h := &restartHTTP{
token: jwtauth.New("HS256", []byte("test-secret"), nil),
dataDir: dataDir,
markerPath: filepath.Join(dataDir, update.RestartRequestFileName),
resultPath: filepath.Join(dataDir, update.ResultFileName),
store: &fakeUpdateStore{result: newStoredUpdateResult(), found: true},
jobs: &fakeJobStore{},
helper: &fakeHelperDetector{installed: true},
clock: &fakeClock{now: restartBaseTime},
}
h.markers = infraUpdate.NewJSONRestartRequestStore(h.markerPath)
h.resultFiles = infraUpdate.NewJSONResultStore(h.resultPath)
container := &api.UseCases{
Token: h.token,
GetUserUseCase: useCases.NewGetUserUseCase(newUpdateUserRepo()),
GetUpdateStatusUseCase: useCases.NewGetUpdateStatusUseCase(
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
RequestRestartUseCase: useCases.NewRequestRestartUseCase(
h.store, h.jobs, h.helper, h.resultFiles, h.markers, updateRunning, h.clock.Now),
}
h.router = api.NewSystemRouter(container)
return h
}
func (h *restartHTTP) readyJob() {
h.jobs.put(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: restartBaseTime.Add(-time.Hour)})
}
func (h *restartHTTP) post(t *testing.T, userId uint32, tokenVersion uint32, body string) *httptest.ResponseRecorder {
t.Helper()
return h.request(t, http.MethodPost, restartPath, userId, tokenVersion, body)
}
func (h *restartHTTP) postAsOwner(t *testing.T, body string) *httptest.ResponseRecorder {
t.Helper()
return h.post(t, updateOwnerId, 2, body)
}
func (h *restartHTTP) request(t *testing.T, method string, target string, userId uint32, tokenVersion uint32, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, strings.NewReader(body))
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 *restartHTTP) getAs(t *testing.T, userId uint32) *httptest.ResponseRecorder {
t.Helper()
tokenVersion := uint32(0)
if userId == updateOwnerId {
tokenVersion = 2
}
return h.request(t, http.MethodGet, "/update", userId, tokenVersion, "")
}
func (h *restartHTTP) markerBytes() ([]byte, bool) {
content, err := os.ReadFile(h.markerPath)
return content, err == nil
}
func (h *restartHTTP) seedResult(t *testing.T, result update.Result) {
t.Helper()
if err := h.resultFiles.Write(result); err != nil {
t.Fatal(err)
}
}
func (h *restartHTTP) assertNoMarkerAndResultKept(t *testing.T, resultKept bool) {
t.Helper()
if _, found := h.markerBytes(); found {
t.Error("no marker may be written")
}
if _, found := h.resultFiles.Read(); found != resultKept {
t.Errorf("result present = %v, want %v", found, resultKept)
}
}
func TestRestartHTTP(t *testing.T) {
t.Run("Request an update restart, happy path", func(t *testing.T) {
for name, body := range map[string]string{"no body": "", "kind update": `{"kind":"update"}`} {
t.Run(name, func(t *testing.T) {
h := newRestartHTTP(t)
h.readyJob()
h.seedResult(t, update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate, Tag: downloadTag, StartedAt: restartBaseTime.Add(-time.Hour), FinishedAt: finishedAt(restartBaseTime.Add(-time.Hour))})
rec := h.postAsOwner(t, body)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d: %s", rec.Code, rec.Body.String())
}
status := decodeUpdateBody(t, rec)
assertUpdateKeys(t, status)
if status["state"] != "restarting" || status["started_at"] != "2026-09-30T09:50:00Z" || status["error"] != nil || status["finished_at"] != nil {
t.Errorf("status = %v", status)
}
marker, err := h.markers.Read()
if err != nil || marker.Kind != "update" || marker.Tag != downloadTag || marker.RequestedBy != updateOwnerId || !marker.RequestedAt.Equal(restartBaseTime) {
t.Errorf("marker = %+v, err = %v", marker, err)
}
if _, found := h.resultFiles.Read(); found {
t.Error("update-result.json must be deleted first")
}
entries, _ := os.ReadDir(h.dataDir)
if len(entries) != 1 {
t.Errorf("only the marker may exist in the data directory, got %d entries", len(entries))
}
asAdmin := decodeUpdateBody(t, h.getAs(t, updateAdminId))
if asAdmin["state"] != "restarting" || asAdmin["can_apply"] != false {
t.Errorf("admin view = %v", asAdmin)
}
})
}
})
t.Run("Request a plain restart", func(t *testing.T) {
for name, body := range map[string]string{"no body": "", "kind restart": `{"kind":"restart"}`, "empty object": `{}`} {
t.Run(name, func(t *testing.T) {
h := newRestartHTTP(t)
rec := h.postAsOwner(t, body)
marker, err := h.markers.Read()
if rec.Code != http.StatusAccepted || err != nil || marker.Kind != "restart" || marker.Tag != "" {
t.Fatalf("status = %d, marker = %+v, err = %v", rec.Code, marker, err)
}
if decodeUpdateBody(t, h.getAs(t, updateOwnerId))["state"] != "restarting" {
t.Error("GET must report restarting")
}
})
}
})
t.Run("The kind must match the state", func(t *testing.T) {
cases := []struct {
name string
prepare func(*restartHTTP)
kind string
}{
{"no job ready (idle) with update", func(*restartHTTP) {}, "update"},
{"a job failed with update", func(h *restartHTTP) {
h.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed, Error: "download"})
}, "update"},
{"a job ready with restart", func(h *restartHTTP) { h.readyJob() }, "restart"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newRestartHTTP(t)
h.seedResult(t, update.Result{Outcome: update.OutcomeFailed, StartedAt: restartBaseTime, FinishedAt: finishedAt(restartBaseTime)})
tc.prepare(h)
jobBefore, _ := h.jobs.Load()
rec := h.postAsOwner(t, `{"kind":"`+tc.kind+`"}`)
jobAfter, _ := h.jobs.Load()
if rec.Code != http.StatusConflict || jobAfter != jobBefore {
t.Errorf("status = %d", rec.Code)
}
h.assertNoMarkerAndResultKept(t, true)
})
}
})
t.Run("Restart site never installs an update another owner just made ready", func(t *testing.T) {
h := newRestartHTTP(t)
h.readyJob()
rec := h.postAsOwner(t, `{"kind":"restart"}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d", rec.Code)
}
h.assertNoMarkerAndResultKept(t, false)
if job, found := h.jobs.Load(); !found || job.State != update.StateReady {
t.Error("the ready job must stay ready")
}
if decodeUpdateBody(t, h.getAs(t, updateOwnerId))["state"] != "ready" {
t.Error("the card must read Ready to install after the reload")
}
})
t.Run("A bad body is a 400", func(t *testing.T) {
for _, body := range []string{`{"kind":"upgrade"}`, `{"kind":""}`, `{"kind":7}`, `{"kind":"update"`, `not json`} {
t.Run(body, func(t *testing.T) {
h := newRestartHTTP(t)
h.readyJob()
h.seedResult(t, update.Result{Outcome: update.OutcomeFailed})
rec := h.postAsOwner(t, body)
if rec.Code != http.StatusBadRequest || rec.Body.String() != apperr.BodyErrorMessage+"\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
h.assertNoMarkerAndResultKept(t, true)
})
}
})
t.Run("POST is guarded like the other system routes", func(t *testing.T) {
cases := []struct {
name string
userId uint32
tokenVersion uint32
status int
body string
}{
{"no jwt cookie", 0, 0, http.StatusUnauthorized, ""},
{"a stale token version", updateOwnerId, 1, http.StatusUnauthorized, apperr.UnauthorizedErrorMessage + "\n"},
{"an unverified owner", updateUnverifiedOwner, 0, http.StatusForbidden, apperr.VerificationRequiredErrorMessage + "\n"},
{"an admin", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"},
{"an admin with a bad body", updateAdminId, 0, http.StatusForbidden, apperr.OwnerRequiredErrorMessage + "\n"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newRestartHTTP(t)
h.seedResult(t, update.Result{Outcome: update.OutcomeFailed})
body := ""
if strings.Contains(tc.name, "bad body") {
body = "not json"
}
rec := h.post(t, tc.userId, tc.tokenVersion, body)
if rec.Code != tc.status || (tc.body != "" && rec.Body.String() != tc.body) {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
h.assertNoMarkerAndResultKept(t, true)
})
}
})
t.Run("The checks run in the documented order", func(t *testing.T) {
h := newRestartHTTP(t)
h.helper.installed = false
if rec := h.postAsOwner(t, "not json"); rec.Code != http.StatusBadRequest {
t.Errorf("a bad body with can_apply off = %d, want 400", rec.Code)
}
if rec := h.postAsOwner(t, `{"kind":"update"}`); rec.Code != http.StatusConflict {
t.Errorf("can_apply off with a mismatching kind = %d, want 409", rec.Code)
}
})
t.Run("Restart is refused with 409", func(t *testing.T) {
cases := []struct {
name string
prepare func(*restartHTTP, *testing.T)
}{
{"can_apply is false (no update-helper.json)", func(h *restartHTTP, _ *testing.T) { h.helper.installed = false }},
{"the job state is downloading", func(h *restartHTTP, _ *testing.T) {
h.jobs.put(update.Job{Tag: downloadTag, State: update.StateDownloading})
}},
{"the job state is verifying", func(h *restartHTTP, _ *testing.T) {
h.jobs.put(update.Job{Tag: downloadTag, State: update.StateVerifying})
}},
{"a marker is already pending", func(h *restartHTTP, t *testing.T) {
if err := h.markers.Write(update.RestartRequest{Kind: "restart", RequestedAt: restartBaseTime, RequestedBy: 9}); err != nil {
t.Fatal(err)
}
}},
{"the result file reads running (helper at work)", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: update.OutcomeRunning, Kind: "update", StartedAt: restartBaseTime.Add(-30 * time.Second)})
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newRestartHTTP(t)
tc.prepare(h, t)
markerBefore, markerExisted := h.markerBytes()
resultBefore, resultExisted := os.ReadFile(h.resultPath)
rec := h.postAsOwner(t, "")
markerAfter, markerExistsNow := h.markerBytes()
resultAfter, resultExistsNow := os.ReadFile(h.resultPath)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d", rec.Code)
}
if markerExisted != markerExistsNow || string(markerBefore) != string(markerAfter) || (resultExisted == nil) != (resultExistsNow == nil) || string(resultBefore) != string(resultAfter) {
t.Error("the existing marker and result must be untouched and no new marker written")
}
})
}
})
t.Run("Two restarts at once write one marker", func(t *testing.T) {
h := newRestartHTTP(t)
var wait sync.WaitGroup
codes := make([]int, 2)
for i, userId := range []uint32{updateOwnerId, updateSecondOwner} {
wait.Add(1)
go func() {
defer wait.Done()
tokenVersion := uint32(0)
if userId == updateOwnerId {
tokenVersion = 2
}
codes[i] = h.post(t, userId, tokenVersion, "").Code
}()
}
wait.Wait()
accepted, refused := 0, 0
for _, code := range codes {
switch code {
case http.StatusAccepted:
accepted++
case http.StatusConflict:
refused++
}
}
entries, _ := os.ReadDir(h.dataDir)
if accepted != 1 || refused != 1 || len(entries) != 1 {
t.Errorf("codes = %v, entries = %d", codes, len(entries))
}
})
t.Run("The marker cannot be written", func(t *testing.T) {
assertFailure := func(t *testing.T, h *restartHTTP) {
t.Helper()
jobBefore, _ := h.jobs.Load()
rec := h.postAsOwner(t, "")
jobAfter, _ := h.jobs.Load()
if rec.Code != http.StatusInternalServerError || rec.Body.String() != "Internal server error\n" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), h.dataDir) || jobAfter != jobBefore {
t.Error("no path may leak and the job record must not change")
}
}
t.Run("a directory sits where the marker goes", func(t *testing.T) {
h := newRestartHTTP(t)
h.readyJob()
if err := os.Mkdir(h.markerPath, 0o755); err != nil {
t.Fatal(err)
}
assertFailure(t, h)
if info, err := os.Stat(h.markerPath); err != nil || !info.IsDir() {
t.Error("the directory must stay")
}
})
t.Run("the data directory is not writable", func(t *testing.T) {
if runtime.GOOS == "windows" || os.Geteuid() == 0 {
t.Skip("directory permissions are not enforced here")
}
h := newRestartHTTP(t)
if err := os.Chmod(h.dataDir, 0o500); err != nil {
t.Fatal(err)
}
defer os.Chmod(h.dataDir, 0o700)
assertFailure(t, h)
if _, found := h.markerBytes(); found {
t.Error("no marker may exist")
}
})
})
t.Run("GET derives the state from the files and never changes them", func(t *testing.T) {
finished := restartBaseTime.Add(-10 * time.Second)
cases := []struct {
name string
files func(*restartHTTP, *testing.T)
state string
errKind any
finished any
}{
{"a marker is present", func(h *restartHTTP, t *testing.T) {
_ = h.markers.Write(update.RestartRequest{Kind: "restart", RequestedAt: restartBaseTime.Add(-time.Second)})
}, "restarting", nil, nil},
{"result running written 30 s ago", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: "running", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-30 * time.Second)})
}, "restarting", nil, nil},
{"result running written 6 minutes ago, no marker", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: "running", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-6 * time.Minute), FinishedAt: &finished})
}, "failed", "install", "2026-09-30T09:49:50Z"},
{"result succeeded, kind update, tag equal to the running version", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "update", Tag: updateRunning, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished})
}, "succeeded", nil, "2026-09-30T09:49:50Z"},
{"result succeeded, kind update, tag older than the running one", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "update", Tag: "v2.1.0-beta", StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished})
}, "idle", nil, "2026-09-30T09:49:50Z"},
{"result succeeded, kind restart", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "restart", StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished})
}, "idle", nil, "2026-09-30T09:49:50Z"},
{"result rolled_back", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: "rolled_back", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished})
}, "failed", "rolled_back", "2026-09-30T09:49:50Z"},
{"result failed", func(h *restartHTTP, t *testing.T) {
h.seedResult(t, update.Result{Outcome: "failed", Kind: "update", Tag: downloadTag, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: &finished})
}, "failed", "install", "2026-09-30T09:49:50Z"},
{"no marker, no result", func(*restartHTTP, *testing.T) {}, "idle", nil, nil},
{"a corrupt result file", func(h *restartHTTP, t *testing.T) {
if err := os.WriteFile(h.resultPath, []byte("{not json"), 0o644); err != nil {
t.Fatal(err)
}
}, "idle", nil, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newRestartHTTP(t)
tc.files(h, t)
before := snapshotDirectory(t, h.dataDir)
first := h.getAs(t, updateAdminId)
second := h.getAs(t, updateAdminId)
body := decodeUpdateBody(t, first)
assertUpdateKeys(t, body)
if first.Body.String() != second.Body.String() {
t.Error("two GETs must answer identically")
}
if body["state"] != tc.state || body["error"] != tc.errKind || body["finished_at"] != tc.finished {
t.Errorf("state = %v, error = %v, finished_at = %v", body["state"], body["error"], body["finished_at"])
}
if after := snapshotDirectory(t, h.dataDir); !equalSnapshots(before, after) {
t.Errorf("a GET changed the data directory: %v -> %v", before, after)
}
})
}
})
t.Run("After a successful update the status is consistent", func(t *testing.T) {
h := newRestartHTTP(t)
h.seedResult(t, update.Result{Outcome: "succeeded", Kind: "update", Tag: updateRunning, StartedAt: restartBaseTime.Add(-time.Minute), FinishedAt: finishedAt(restartBaseTime)})
h.store.result.Latest = updateRunning
body := decodeUpdateBody(t, h.getAs(t, updateOwnerId))
if body["state"] != "succeeded" || body["available"] != false || body["current"] != updateRunning || body["finished_at"] == nil {
t.Errorf("body = %v", body)
}
})
}
type directorySnapshot map[string]string
func snapshotDirectory(t *testing.T, directory string) directorySnapshot {
t.Helper()
snapshot := directorySnapshot{}
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
info, _ := entry.Info()
content, _ := os.ReadFile(filepath.Join(directory, entry.Name()))
snapshot[entry.Name()] = string(content) + "|" + info.ModTime().String()
}
return snapshot
}
func equalSnapshots(a, b directorySnapshot) bool {
if len(a) != len(b) {
return false
}
for name, value := range a {
if b[name] != value {
return false
}
}
return true
}
func TestRestartResultLivesUntilTheNextJob(t *testing.T) {
t.Run("The result lives until the next job", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rig.results.put(update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate, Tag: downloadTag, StartedAt: restartBaseTime, FinishedAt: finishedAt(restartBaseTime)})
before := decodeUpdateBody(t, h.sendAs(t, http.MethodGet, "/update", updateOwnerId))
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
ready := waitForHTTPState(t, h, "ready", "failed")
if before["state"] != "failed" || before["error"] != "rolled_back" || before["finished_at"] == nil {
t.Errorf("before = %v", before)
}
if rec.Code != http.StatusAccepted || rig.results.deletes != 1 || rig.results.found {
t.Errorf("status = %d, deletes = %d", rec.Code, rig.results.deletes)
}
assertUpdateKeys(t, ready)
if ready["state"] != "ready" || ready["error"] != nil || ready["finished_at"] != nil {
t.Errorf("ready = %v", ready)
}
})
t.Run("A download is refused while a restart is pending", func(t *testing.T) {
rig := newDownloadRig(t)
h := newDownloadHTTP(t, rig)
rig.requests.present = true
rig.results.put(update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime})
rec := h.sendAs(t, http.MethodPost, downloadPath, updateOwnerId)
if rec.Code != http.StatusConflict || rig.results.deletes != 0 || rig.fetcher.totalCalls() != 0 {
t.Errorf("status = %d", rec.Code)
}
})
}
func TestHealthzIsUnchanged(t *testing.T) {
t.Run("Healthz is unchanged", func(t *testing.T) {
container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Errorf("status = %d body = %q", rec.Code, rec.Body.String())
}
for name := range rec.Header() {
if strings.Contains(strings.ToLower(name), "version") {
t.Errorf("unexpected header %s", name)
}
}
})
}
func TestRunningInfoFileAtStartup(t *testing.T) {
t.Run("The server writes update-running.json at startup", func(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, update.RunningInfoFileName)
store := infraUpdate.NewJSONRunningInfoStore(path)
first := &fakeClock{now: restartBaseTime}
if err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 111, first.Now).Record(); err != nil {
t.Fatal(err)
}
content, _ := os.ReadFile(path)
var written map[string]any
_ = json.Unmarshal(content, &written)
if written["version"] != "v2.3.0-beta" || written["pid"] != float64(111) || written["started_at"] != "2026-09-30T09:50:00Z" || len(written) != 3 {
t.Errorf("file = %s", content)
}
later := &fakeClock{now: restartBaseTime.Add(time.Minute)}
if err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 222, later.Now).Record(); err != nil {
t.Fatal(err)
}
info, found := store.Read()
if !found || info.Pid != 222 || !info.StartedAt.Equal(later.now) {
t.Errorf("a second start must replace the file: %+v", info)
}
if entries, _ := os.ReadDir(directory); len(entries) != 1 {
t.Errorf("the temporary file must be renamed away, got %d entries", len(entries))
}
})
t.Run("a write error is returned and nothing panics", func(t *testing.T) {
store := infraUpdate.NewJSONRunningInfoStore(filepath.Join(t.TempDir(), "missing", update.RunningInfoFileName))
err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 1, time.Now).Record()
if err == nil {
t.Error("the error must reach the startup code")
}
})
t.Run("the file is not served by any route", func(t *testing.T) {
container := &api.UseCases{Token: jwtauth.New("HS256", []byte("test-secret"), nil)}
router := api.InitRoutes(container, config.Settings{Environment: "production"})
for _, target := range []string{"/update-running.json", "/v1/update-running.json", "/static/uploadedImages/update-running.json"} {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
if rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "pid") {
t.Errorf("%s served the file", target)
}
}
})
}
+317
View File
@@ -0,0 +1,317 @@
package test
import (
"RenewCMS/internal/domain/update"
"RenewCMS/internal/infrastructure/useCases"
"errors"
"sync"
"testing"
"time"
)
var restartBaseTime = time.Date(2026, 9, 30, 9, 50, 0, 0, time.UTC)
type restartRig struct {
store *fakeUpdateStore
jobs *fakeJobStore
helper *fakeHelperDetector
results *fakeResultStore
requests *fakeRestartRequestStore
clock *fakeClock
request *useCases.RequestRestartUseCase
status *useCases.GetUpdateStatusUseCase
}
func newRestartRig(running string) *restartRig {
rig := &restartRig{
store: &fakeUpdateStore{result: newStoredUpdateResult(), found: true},
jobs: &fakeJobStore{},
helper: &fakeHelperDetector{installed: true},
results: &fakeResultStore{},
requests: &fakeRestartRequestStore{},
clock: &fakeClock{now: restartBaseTime},
}
rig.request = useCases.NewRequestRestartUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
rig.status = useCases.NewGetUpdateStatusUseCase(rig.store, rig.jobs, rig.helper, rig.results, rig.requests, running, rig.clock.Now)
return rig
}
func (r *restartRig) readyJob() {
r.jobs.put(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: restartBaseTime.Add(-time.Hour)})
}
func finishedAt(at time.Time) *time.Time {
return &at
}
func TestRequestRestartUseCase(t *testing.T) {
t.Run("a ready job gives an update marker with the tag and the requester", func(t *testing.T) {
rig := newRestartRig(updateRunning)
rig.readyJob()
rig.results.put(update.Result{Outcome: update.OutcomeRolledBack})
status, err := rig.request.Request(updateOwnerId, "")
if err != nil {
t.Fatal(err)
}
marker, markerErr := rig.requests.Read()
if markerErr != nil || marker.Kind != update.RestartKindUpdate || marker.Tag != downloadTag || marker.RequestedBy != updateOwnerId || !marker.RequestedAt.Equal(restartBaseTime) {
t.Errorf("marker = %+v, err = %v", marker, markerErr)
}
if status.State != update.StateRestarting || status.StartedAt == nil || !status.StartedAt.Equal(marker.RequestedAt) || !status.CanApply {
t.Errorf("status = %+v", status)
}
if rig.results.deletes != 1 || rig.results.found {
t.Errorf("the result must be deleted first: deletes = %d", rig.results.deletes)
}
})
t.Run("no ready job gives a plain restart marker without a tag", func(t *testing.T) {
for name, prepare := range map[string]func(*restartRig){
"idle": func(*restartRig) {},
"failed job": func(r *restartRig) {
r.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed, Error: "download"})
},
"rolled back": func(r *restartRig) {
r.results.put(update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate})
},
} {
t.Run(name, func(t *testing.T) {
rig := newRestartRig(updateRunning)
prepare(rig)
status, err := rig.request.Request(updateOwnerId, update.RestartKindRestart)
marker, _ := rig.requests.Read()
if err != nil || marker.Kind != update.RestartKindRestart || marker.Tag != "" || status.State != update.StateRestarting {
t.Errorf("err = %v, marker = %+v, status = %+v", err, marker, status)
}
})
}
})
t.Run("a kind that does not match the state is refused and nothing changes", func(t *testing.T) {
cases := map[string]struct {
prepare func(*restartRig)
kind string
}{
"update without a ready job": {func(*restartRig) {}, update.RestartKindUpdate},
"update after a failed job": {func(r *restartRig) { r.jobs.put(update.Job{Tag: downloadTag, State: update.StateFailed}) }, update.RestartKindUpdate},
"restart with a ready job": {func(r *restartRig) { r.readyJob() }, update.RestartKindRestart},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
rig := newRestartRig(updateRunning)
rig.results.put(update.Result{Outcome: update.OutcomeFailed})
tc.prepare(rig)
jobBefore, _ := rig.jobs.Load()
_, err := rig.request.Request(updateOwnerId, tc.kind)
if !errors.Is(err, update.ErrRestartKindMismatch) {
t.Fatalf("err = %v", err)
}
jobAfter, _ := rig.jobs.Load()
if rig.requests.writes != 0 || rig.results.deletes != 0 || !rig.results.found || jobAfter != jobBefore {
t.Error("nothing may change on a mismatch")
}
})
}
})
t.Run("a restart is refused while it cannot or must not run", func(t *testing.T) {
cases := map[string]struct {
prepare func(*restartRig)
want error
}{
"no helper": {func(r *restartRig) { r.helper.installed = false }, update.ErrNotPrepared},
"downloading": {func(r *restartRig) { r.jobs.put(update.Job{State: update.StateDownloading}) }, update.ErrJobInProgress},
"verifying": {func(r *restartRig) { r.jobs.put(update.Job{State: update.StateVerifying}) }, update.ErrJobInProgress},
"marker pending": {func(r *restartRig) { r.requests.present = true }, update.ErrRestartPending},
"helper at work": {func(r *restartRig) {
r.results.put(update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-time.Minute)})
}, update.ErrRestartPending},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
rig := newRestartRig(updateRunning)
tc.prepare(rig)
resultBefore, foundBefore := rig.results.Read()
writesBefore := rig.requests.writes
_, err := rig.request.Request(updateOwnerId, "")
resultAfter, foundAfter := rig.results.Read()
if !errors.Is(err, tc.want) || rig.requests.writes != writesBefore || resultAfter != resultBefore || foundAfter != foundBefore || rig.results.deletes != 0 {
t.Errorf("err = %v", err)
}
})
}
})
t.Run("a stale running result does not block a new restart", func(t *testing.T) {
rig := newRestartRig(updateRunning)
rig.results.put(update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-6 * time.Minute)})
_, err := rig.request.Request(updateOwnerId, "")
if err != nil || rig.requests.writes != 1 {
t.Errorf("err = %v, writes = %d", err, rig.requests.writes)
}
})
t.Run("a marker write error is returned and leaves the job alone", func(t *testing.T) {
rig := newRestartRig(updateRunning)
rig.readyJob()
rig.requests.writeErr = errors.New("disk full at /var/lib/renewcms")
jobBefore, _ := rig.jobs.Load()
_, err := rig.request.Request(updateOwnerId, "")
jobAfter, _ := rig.jobs.Load()
if err == nil || errors.Is(err, update.ErrRestartKindMismatch) || rig.requests.present || jobAfter != jobBefore {
t.Errorf("err = %v", err)
}
})
t.Run("two simultaneous requests write one marker", func(t *testing.T) {
rig := newRestartRig(updateRunning)
var wait sync.WaitGroup
errs := make([]error, 2)
for i := range errs {
wait.Add(1)
go func() {
defer wait.Done()
_, errs[i] = rig.request.Request(uint32(i+1), "")
}()
}
wait.Wait()
accepted, refused := 0, 0
for _, err := range errs {
switch {
case err == nil:
accepted++
case errors.Is(err, update.ErrRestartPending):
refused++
}
}
if accepted != 1 || refused != 1 || rig.requests.writes != 1 {
t.Errorf("accepted = %d, refused = %d, writes = %d", accepted, refused, rig.requests.writes)
}
})
}
func (f *fakeResultStore) put(result update.Result) {
f.mu.Lock()
defer f.mu.Unlock()
f.result = result
f.found = true
}
func TestUpdateStatusFromFiles(t *testing.T) {
running := updateRunning
started := restartBaseTime.Add(-30 * time.Second)
finished := restartBaseTime.Add(-10 * time.Second)
cases := []struct {
name string
marker bool
result *update.Result
corrupt bool
state string
errKind string
finished *time.Time
}{
{"a marker is present", true, nil, false, "restarting", "", nil},
{"a marker wins over a finished result", true, &update.Result{Outcome: update.OutcomeFailed, StartedAt: started, FinishedAt: &finished}, false, "restarting", "", &finished},
{"result running written 30 s ago", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: started}, false, "restarting", "", nil},
{"result running just under 5 minutes", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-5*time.Minute + time.Second)}, false, "restarting", "", nil},
{"result running for 5 minutes, no marker", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-5 * time.Minute), FinishedAt: &finished}, false, "failed", "install", &finished},
{"result running written 6 minutes ago, no marker", false, &update.Result{Outcome: update.OutcomeRunning, StartedAt: restartBaseTime.Add(-6 * time.Minute), FinishedAt: &finished}, false, "failed", "install", &finished},
{"succeeded update with the running tag", false, &update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindUpdate, Tag: running, StartedAt: started, FinishedAt: &finished}, false, "succeeded", "", &finished},
{"succeeded update with an older tag", false, &update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindUpdate, Tag: "v2.1.0-beta", StartedAt: started, FinishedAt: &finished}, false, "idle", "", &finished},
{"succeeded plain restart", false, &update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindRestart, StartedAt: started, FinishedAt: &finished}, false, "idle", "", &finished},
{"rolled back", false, &update.Result{Outcome: update.OutcomeRolledBack, Kind: update.RestartKindUpdate, Tag: "v2.3.0-beta", StartedAt: started, FinishedAt: &finished}, false, "failed", "rolled_back", &finished},
{"failed", false, &update.Result{Outcome: update.OutcomeFailed, Kind: update.RestartKindUpdate, StartedAt: started, FinishedAt: &finished}, false, "failed", "install", &finished},
{"no marker, no result", false, nil, false, "idle", "", nil},
{"a corrupt result file", false, nil, true, "idle", "", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rig := newRestartRig(running)
rig.requests.present = tc.marker
if tc.marker {
rig.requests.request = update.RestartRequest{Kind: update.RestartKindRestart, RequestedAt: started}
}
if tc.result != nil {
rig.results.put(*tc.result)
}
if tc.marker || tc.result != nil {
rig.jobs.put(update.Job{Tag: downloadTag, State: update.StateReady, StartedAt: started})
}
first := rig.status.GetStatus(true)
second := rig.status.GetStatus(true)
if first.State != tc.state || (tc.errKind == "") != (first.Error == nil) || (first.Error != nil && *first.Error != tc.errKind) {
t.Errorf("state = %q, error = %v", first.State, first.Error)
}
if (tc.finished == nil) != (first.FinishedAt == nil) || (tc.finished != nil && !first.FinishedAt.Equal(*tc.finished)) {
t.Errorf("finished_at = %v", first.FinishedAt)
}
if first.Progress != nil && tc.state != "ready" {
t.Errorf("progress = %v", first.Progress)
}
if first.State != second.State || rig.results.deletes != 0 || rig.requests.deletes != 0 || rig.results.writes != nil || rig.requests.writes != 0 {
t.Error("GET must not change anything")
}
})
}
t.Run("without any file a ready job is reported as ready", func(t *testing.T) {
rig := newRestartRig(running)
rig.readyJob()
status := rig.status.GetStatus(true)
if status.State != update.StateReady || status.FinishedAt != nil {
t.Errorf("status = %+v", status)
}
})
t.Run("after a successful update nothing is available and current is the new version", func(t *testing.T) {
rig := newRestartRig(downloadTag)
rig.results.put(update.Result{Outcome: update.OutcomeSucceeded, Kind: update.RestartKindUpdate, Tag: downloadTag, StartedAt: started, FinishedAt: &finished})
status := rig.status.GetStatus(true)
if status.State != update.StateSucceeded || status.Available || status.Current != downloadTag || status.FinishedAt == nil {
t.Errorf("status = %+v", status)
}
})
}
func TestRecordRunningInfoUseCase(t *testing.T) {
t.Run("it writes the version, the pid and the start instant", func(t *testing.T) {
store := &fakeRunningInfoStore{}
clock := &fakeClock{now: restartBaseTime}
err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 4242, clock.Now).Record()
want := update.RunningInfo{Version: "v2.3.0-beta", Pid: 4242, StartedAt: restartBaseTime}
if err != nil || len(store.writes) != 1 || store.writes[0] != want {
t.Errorf("err = %v, writes = %+v", err, store.writes)
}
})
t.Run("a write error is returned, not raised", func(t *testing.T) {
store := &fakeRunningInfoStore{writeErr: errors.New("read-only file system")}
err := useCases.NewRecordRunningInfoUseCase(store, "v2.3.0-beta", 1, time.Now).Record()
if err == nil {
t.Error("the error must be returned so the startup code can log it")
}
})
}
+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{}, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true)
status := newUpdateStatusUseCase(&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, &fakeJobStore{}, &fakeHelperDetector{}, "v2.2.0-beta").GetStatus(true)
status := newUpdateStatusUseCase(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, &fakeJobStore{}, &fakeHelperDetector{}, "dev").GetStatus(true).Available {
if newUpdateStatusUseCase(store, &fakeJobStore{}, &fakeHelperDetector{}, "dev").GetStatus(true).Available {
t.Error("a dev build must not report an update")
}
})