Files
stalwart-migrator/internal/validate/bootcheck_test.go
T
jcoffey-dev 719a945d64 Initial commit: stalwart-migrator design and scaffolding
In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with
checkpointed rollback and post-migration validation. Design stage; see
ARCHITECTURE.md.
2026-08-22 18:17:17 -07:00

252 lines
8.0 KiB
Go

package validate
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/johnellis/stalwart-migrator/internal/checkpoint"
)
func freePort(t *testing.T) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
return ln.Addr().(*net.TCPAddr).Port
}
func testBinaryPath(t *testing.T) string {
t.Helper()
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return self
}
func TestBootCheckSucceedsWhenInstanceComesUp(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if detail == "" {
t.Error("BootCheck returned an empty detail on success")
}
if result != nil {
t.Errorf("result = %+v, want nil when ContentIntegrityBefore wasn't set", result)
}
}
func TestBootCheckFailsWhenInstanceNeverComesUp(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
binPath := filepath.Join(t.TempDir(), "stalwart")
os.WriteFile(binPath, []byte("#!/bin/sh\nsleep 5\n"), 0o755)
port := freePort(t)
_, _, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: binPath,
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
Timeout: 300 * time.Millisecond,
StopGrace: 2 * time.Second,
})
if err == nil {
t.Fatal("BootCheck should fail when nothing ever answers ListenURL")
}
}
func TestRunEndToEndAndResume(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("0.15.5", "0.16.14")
if err != nil {
t.Fatal(err)
}
opts := BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
}
report, err := Run(context.Background(), store, rs, opts)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if report.Blocking() {
t.Fatalf("Run #1: unexpected failure: %s", report.String())
}
// Resume with a config that would fail if re-executed (nothing listens
// on badPort) - a skip proves the step didn't re-run.
badPort := freePort(t)
resumedOpts := opts
resumedOpts.ListenURL = fmt.Sprintf("http://127.0.0.1:%d/", badPort)
resumedOpts.Timeout = 300 * time.Millisecond
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatal(err)
}
report2, err := Run(context.Background(), store, resumed, resumedOpts)
if err != nil {
t.Fatalf("Run #2 (resume) should succeed without redoing the check: %v", err)
}
if report2.Blocking() {
t.Fatalf("Run #2 (resume): unexpected failure: %s", report2.String())
}
}
// beforeSnapshotWithAliceInbox builds a checkpoint.PreflightSnapshot
// matching the fake server's single hardcoded account ([email protected],
// mailbox "Inbox") with the given pre-migration message count.
func beforeSnapshotWithAliceInbox(messages int) *checkpoint.PreflightSnapshot {
return &checkpoint.PreflightSnapshot{
AccountCount: 1,
Domains: []string{"example.com"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: messages}},
},
}
}
func TestBootCheckContentIntegrityPassesWhenCountsMatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
detail, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42", // matches beforeSnapshotWithAliceInbox(42)
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42),
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err != nil {
t.Fatalf("BootCheck: %v", err)
}
if result == nil {
t.Fatal("result should be populated when ContentIntegrityBefore was set")
}
if !result.OK() {
t.Errorf("result.OK() = false, want true: %s", result.String())
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 1 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 1", result.AccountsChecked, result.MailboxesChecked)
}
if detail == "" {
t.Error("detail should still describe the boot")
}
}
func TestBootCheckContentIntegrityFailsWhenCountsMismatch(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=40", // the "after" server reports 40
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: beforeSnapshotWithAliceInbox(42), // but "before" said 42 - two messages went missing
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when a post-migration mailbox count doesn't match the pre-migration one")
}
if result == nil || result.OK() {
t.Fatalf("result = %+v, want a non-OK result describing the mismatch", result)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one entry", result.MessageCountMismatches)
}
mismatch := result.MessageCountMismatches[0]
if mismatch.Account != "[email protected]" || mismatch.Mailbox != "Inbox" || mismatch.Before != 42 || mismatch.After != 40 {
t.Errorf("mismatch = %+v, want [email protected]/Inbox 42->40", mismatch)
}
}
func TestBootCheckContentIntegrityDetectsMissingAccount(t *testing.T) {
port := freePort(t)
configPath := filepath.Join(t.TempDir(), "config.json")
os.WriteFile(configPath, []byte("{}"), 0o644)
before := &checkpoint.PreflightSnapshot{
AccountCount: 2,
Domains: []string{"example.com", "example.net"},
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {{Mailbox: "Inbox", Messages: 42}},
"[email protected]": {{Mailbox: "Inbox", Messages: 5}}, // the fake server only ever knows about alice
},
}
_, result, err := BootCheck(context.Background(), BootCheckOptions{
BinaryPath: testBinaryPath(t),
ConfigPath: configPath,
ListenURL: fmt.Sprintf("http://127.0.0.1:%d/", port),
ExtraEnv: []string{
"STALWART_MIGRATOR_TEST_HELPER=1",
fmt.Sprintf("STALWART_MIGRATOR_TEST_PORT=%d", port),
"STALWART_MIGRATOR_TEST_MAILBOX_COUNT=42",
},
Timeout: 5 * time.Second,
StopGrace: 5 * time.Second,
ContentIntegrityBefore: before,
AdminUser: "admin",
AdminPassword: "hunter2",
})
if err == nil {
t.Fatal("BootCheck should fail when an account present before migration can't be found afterward")
}
if result == nil || len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "[email protected]" {
t.Fatalf("result = %+v, want MissingAccounts = [[email protected]]", result)
}
}