Files
RenewCMS/test/update_helper_gaps_test.go
T

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)
}
})
}