Files
RenewCMS/test/update_helper_install_test.go

467 lines
16 KiB
Go

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