400 lines
13 KiB
Go
400 lines
13 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|