119 SPDX-FileCopyrightText headers and the README's licence line. The distinction that matters here: LINUXexpert-org appears in this repository in two completely different roles. As a copyright holder in the SPDX headers, which is what changes, and as the GitHub organisation in the module path and 64 import statements, which does not -- the repository still lives at github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a licence change, it would break the build. Both replacements are anchored to their copyright forms, so an import path cannot match either. Import count is 64 before and after, and go.mod is untouched. LICENSE untouched: the FSF's copyright on the GPL text and the "<name of author>" placeholders are not ours to edit. go vet, go build and go test all clean.
232 lines
6.8 KiB
Go
232 lines
6.8 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNewRefusesUnknownKind(t *testing.T) {
|
|
for _, kind := range []Kind{Unknown, "", "kubernetes"} {
|
|
if _, err := New(Options{Kind: kind}); err == nil {
|
|
t.Errorf("New(%q): want error, got nil - guessing how to stop Stalwart is exactly what this must not do", kind)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewDefaultsTargetNames(t *testing.T) {
|
|
systemd, err := New(Options{Kind: Systemd})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := systemd.Target(), "systemd unit stalwart"; got != want {
|
|
t.Errorf("Target() = %q, want %q", got, want)
|
|
}
|
|
docker, err := New(Options{Kind: Docker})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got, want := docker.Target(), "docker container stalwart"; got != want {
|
|
t.Errorf("Target() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSystemdStopStartReloadInvocations(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
withFakeExecutable(t, "systemctl", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
c, err := New(Options{Kind: Systemd, UnitName: "stalwart-mail"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
if err := c.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.ReloadConfig(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
want := "stop stalwart-mail\ndaemon-reload\nstart stalwart-mail\n"
|
|
if got := readArgsFile(t, log); got != want {
|
|
t.Errorf("systemctl invocations:\ngot: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestSystemdStopReportsCommandFailure(t *testing.T) {
|
|
withFakeExecutable(t, "systemctl", "#!/bin/sh\necho 'Failed to stop stalwart.service: Access denied' >&2\nexit 1\n")
|
|
c, err := New(Options{Kind: Systemd})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = c.Stop(context.Background())
|
|
if err == nil {
|
|
t.Fatal("Stop: want error when systemctl fails, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "Access denied") {
|
|
t.Errorf("Stop error %q does not carry systemctl's own output, which is the only clue an operator gets", err)
|
|
}
|
|
}
|
|
|
|
// systemctl exits non-zero for every non-active state, so Active has to
|
|
// read its output rather than its exit status - otherwise "inactive", the
|
|
// answer a caller waiting for a clean stop most needs, would look like a
|
|
// failure to read the state at all.
|
|
func TestSystemdActiveReadsOutputNotExitStatus(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
state string
|
|
exitCode int
|
|
want bool
|
|
}{
|
|
{"active", 0, true},
|
|
{"activating", 3, true},
|
|
{"reloading", 3, true},
|
|
{"deactivating", 3, true}, // still holding the data directory open
|
|
{"inactive", 3, false},
|
|
{"failed", 3, false},
|
|
} {
|
|
t.Run(tc.state, func(t *testing.T) {
|
|
withFakeExecutable(t, "systemctl", fmt.Sprintf("#!/bin/sh\necho %s\nexit %d\n", tc.state, tc.exitCode))
|
|
c, err := New(Options{Kind: Systemd})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
active, err := c.Active(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Active() for state %q: unexpected error %v", tc.state, err)
|
|
}
|
|
if active != tc.want {
|
|
t.Errorf("Active() for state %q = %v, want %v", tc.state, active, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSystemdActiveErrorsOnUnrecognizedState(t *testing.T) {
|
|
withFakeExecutable(t, "systemctl", "#!/bin/sh\necho 'command not found'\nexit 127\n")
|
|
c, err := New(Options{Kind: Systemd})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.Active(context.Background()); err == nil {
|
|
t.Error("Active: want error for unreadable state, got nil - an unreadable state must never collapse into 'not running'")
|
|
}
|
|
}
|
|
|
|
func TestDockerStopStartInvocations(t *testing.T) {
|
|
dir := t.TempDir()
|
|
log := argsFile(t, dir)
|
|
withFakeExecutable(t, "docker", fakeScriptLoggingArgs(log, "exit 0"))
|
|
|
|
c, err := New(Options{Kind: Docker, ContainerName: "mail"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
if err := c.Stop(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := c.ReloadConfig(ctx); err != nil {
|
|
t.Fatalf("ReloadConfig should be a no-op for docker: %v", err)
|
|
}
|
|
|
|
want := "stop mail\nstart mail\n"
|
|
if got := readArgsFile(t, log); got != want {
|
|
t.Errorf("docker invocations:\ngot: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDockerActive(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
out string
|
|
want bool
|
|
}{{"true", true}, {"false", false}} {
|
|
t.Run(tc.out, func(t *testing.T) {
|
|
withFakeExecutable(t, "docker", fmt.Sprintf("#!/bin/sh\necho %s\n", tc.out))
|
|
c, err := New(Options{Kind: Docker})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
active, err := c.Active(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if active != tc.want {
|
|
t.Errorf("Active() = %v, want %v", active, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDockerActiveErrorsWhenContainerMissing(t *testing.T) {
|
|
withFakeExecutable(t, "docker", "#!/bin/sh\necho 'Error: No such object: stalwart' >&2\nexit 1\n")
|
|
c, err := New(Options{Kind: Docker})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := c.Active(context.Background()); err == nil {
|
|
t.Error("Active: want error when the container doesn't exist, got nil")
|
|
}
|
|
}
|
|
|
|
// WaitFor exists because systemctl and docker both return as soon as the
|
|
// *request* succeeded: this proves it keeps polling past a still-running
|
|
// state rather than accepting the first answer.
|
|
func TestWaitForPollsUntilStateChanges(t *testing.T) {
|
|
dir := t.TempDir()
|
|
counter := dir + "/calls"
|
|
withFakeExecutable(t, "systemctl", fmt.Sprintf(
|
|
"#!/bin/sh\nn=$(cat %[1]q 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > %[1]q\n"+
|
|
"if [ $n -lt 3 ]; then echo deactivating; exit 3; fi\necho inactive; exit 3\n", counter))
|
|
|
|
c, err := New(Options{Kind: Systemd})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := WaitFor(context.Background(), c, false, 5*time.Second); err != nil {
|
|
t.Fatalf("WaitFor: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWaitForTimesOutWhileStillActive(t *testing.T) {
|
|
withFakeExecutable(t, "systemctl", "#!/bin/sh\necho active\n")
|
|
c, err := New(Options{Kind: Systemd})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = WaitFor(context.Background(), c, false, 300*time.Millisecond)
|
|
if err == nil {
|
|
t.Fatal("WaitFor: want timeout error while the unit is still active, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "not stopped") {
|
|
t.Errorf("WaitFor error %q should say what it was waiting for", err)
|
|
}
|
|
}
|
|
|
|
func TestWaitForSurfacesLastStateReadError(t *testing.T) {
|
|
withFakeExecutable(t, "systemctl", "#!/bin/sh\necho 'no such unit' >&2\nexit 4\n")
|
|
c, err := New(Options{Kind: Systemd})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = WaitFor(context.Background(), c, false, 300*time.Millisecond)
|
|
if err == nil {
|
|
t.Fatal("WaitFor: want error when the state can't be read at all, got nil")
|
|
}
|
|
if !strings.Contains(err.Error(), "state couldn't be read") {
|
|
t.Errorf("WaitFor error %q should distinguish 'never reached the state' from 'never could tell'", err)
|
|
}
|
|
}
|