feat: restart to apply update, privileged helper, rollback
This commit is contained in:
1 parent
70460e4170
commit
54e8fd5fed
63 files changed
+8635
-164
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user