GPLv3's "How to Apply These Terms" asks for a notice in each source file; this is the modern two-line SPDX form of it rather than the full paragraph. 82 files, including tests. The blank line after the header is load-bearing. In Go a comment block immediately preceding `package X` becomes the package doc comment, so without the separator the SPDX lines would be absorbed into the doc for the eleven packages whose doc.go (or main.go) opens with one, and `go doc` would print them. Verified it doesn't.
232 lines
6.8 KiB
Go
232 lines
6.8 KiB
Go
// SPDX-FileCopyrightText: 2026 LINUXexpert-org
|
|
// 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)
|
|
}
|
|
}
|