467 lines
16 KiB
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)
|
|
}
|
|
})
|
|
}
|