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