Files
stalwart-migrator/internal/preflight/checks_test.go
T
jcoffey-dev 4568f9abbf Capture the pre-migration snapshot from 0.15.x, and stop claiming counts match when none were compared
Found by running preflight against a real Stalwart 0.15.5 in a VM. Two
defects, the second worse than the first.

1. AccountSnapshot could not read the version this tool migrates FROM.
   0.15.5 advertises no urn:stalwart:jmap capability and POST /api returns
   404 - the JMAP management API and x:Account are 0.16 features. 0.15.x
   exposes a REST API at GET /api/principal instead. So preflight's
   account-snapshot check warned and moved on, and every run against a real
   source instance had no "before" data at all.

   AccountSnapshot now dispatches on the capability the session document
   advertises - a positive signal, not an inference from a failed call -
   and internal/stalwartapi/principal.go implements the 0.15.x REST path,
   including its 1-based page/limit pagination so an install larger than
   one page isn't silently truncated.

2. With no "before" counts, the content-integrity comparison iterated an
   empty map, checked nothing, and reported "all message counts match".
   That is the strongest claim this tool makes - ARCHITECTURE 4.7 calls it
   the actual no-data-loss guarantee - made vacuously, and it would have
   passed on a migration that lost every message.

   The comparison now derives its account set from whatever the source
   could report, verifies every account and domain survived either way, and
   carries MessageCountsCompared so the report says plainly "MESSAGE COUNTS
   NOT COMPARED ... no-data-loss is NOT verified here" rather than implying
   otherwise.

What can and cannot be checked across the 0.15/0.16 boundary, now that a
real server has answered: 0.15.x has no per-mailbox message count at any
endpoint, and the impersonation login 0.16 offers returns 401 there, so
before/after message counts are impossible for the boundary migration this
tool exists for. Both versions do report per-account used quota (usedQuota
in 0.15's REST list, usedDiskQuota on 0.16's x:Account), so that is
captured on both sides. It is recorded and reported, not asserted on:
4.5 notes the 0.16 migration resets quotas to zero pending recalculation,
so comparing those bytes across the boundary would be a false alarm
generator.

Test servers across preflight, validate and stalwartapi now advertise
urn:stalwart:jmap, since they stand in for 0.16 instances and that
capability is what says so.

Verified end to end against the smoke VM: all nine preflight checks pass,
and the checkpoint records 2 accounts, 1 domain and per-account used quota
where it previously recorded nothing.
2026-08-23 18:51:19 -07:00

313 lines
10 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package preflight
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
)
// writeFakeBinary creates a shell script that behaves like `stalwart
// --version` and records each invocation to counterPath, so tests can
// assert a checkpointed step was (or wasn't) re-executed on resume.
func writeFakeBinary(t *testing.T, version, counterPath string) string {
t.Helper()
dir := t.TempDir()
scriptPath := filepath.Join(dir, "fake-stalwart.sh")
script := fmt.Sprintf("#!/bin/sh\necho invoked >> %q\necho 'stalwart %s'\n", counterPath, version)
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return scriptPath
}
func countLines(t *testing.T, path string) int {
t.Helper()
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return 0
}
if err != nil {
t.Fatal(err)
}
return len(strings.Split(strings.TrimSpace(string(data)), "\n"))
}
func TestCheckerRunEndToEndAndResume(t *testing.T) {
// -- fixtures --------------------------------------------------------
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
tomlCfg := "[store.\"rocksdb\"]\ntype = \"rocksdb\"\npath = \"/var/lib/stalwart/data\"\n"
if err := os.WriteFile(configPath, []byte(tomlCfg), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{
TagName: "v0.16.14",
Assets: []ReleaseAsset{{Name: "checksums.txt"}},
})
})
stateDir := t.TempDir()
store := checkpoint.NewStore(stateDir)
rs, err := store.Create("", "latest")
if err != nil {
t.Fatalf("store.Create: %v", err)
}
checker := New(Options{
BinaryPath: binaryPath,
ConfigPath: configPath,
DataDir: dataDir,
TargetVersion: "latest",
})
// -- first run: everything should execute -----------------------------
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run #1: %v", err)
}
if report.Blocking() {
t.Fatalf("Run #1: unexpected blocking report:\n%s", report.String())
}
if got := countLines(t, counterPath); got != 1 {
t.Fatalf("binary invocations after Run #1 = %d, want 1", got)
}
if rs.SourceVersion != "0.15.5" {
t.Errorf("rs.SourceVersion = %q, want 0.15.5", rs.SourceVersion)
}
if rs.TargetVersion != "0.16.14" {
t.Errorf("rs.TargetVersion = %q, want 0.16.14 (resolved from \"latest\")", rs.TargetVersion)
}
if rs.Topology.StoreBackend != "rocksdb" {
t.Errorf("rs.Topology.StoreBackend = %q, want rocksdb", rs.Topology.StoreBackend)
}
foundDirection := false
for _, res := range report.Results {
if res.Name == "upgrade-direction" {
foundDirection = true
if !strings.Contains(res.Detail, "major boundary") {
t.Errorf("upgrade-direction detail = %q, want mention of the major boundary", res.Detail)
}
}
}
if !foundDirection {
t.Error("report missing upgrade-direction check")
}
// -- simulate a crash and resume: reload state from disk fresh --------
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load (resume): %v", err)
}
report2, err := checker.Run(context.Background(), store, resumed)
if err != nil {
t.Fatalf("Run #2 (resume): %v", err)
}
if got := countLines(t, counterPath); got != 1 {
t.Errorf("binary invocations after resumed Run = %d, want 1 (already-done steps must not re-execute)", got)
}
if len(report2.Results) != len(report.Results) {
t.Errorf("resumed report has %d results, want %d (same as first run)", len(report2.Results), len(report.Results))
}
}
func TestCheckerRunFlagsTooOldSource(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.14.2", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[server]\nhostname = \"mail.example.com\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
checker := New(Options{BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest"})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !report.Blocking() {
t.Fatalf("expected a blocking report for a too-old source version, got:\n%s", report.String())
}
}
func TestCheckerRunFlagsInsufficientDiskSpace(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
// An absurd multiple guarantees the free-space check fails regardless
// of how much space the test host actually has free.
checker := New(Options{
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir,
TargetVersion: "latest", MinFreeMultiple: 1e12,
})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if !report.Blocking() {
t.Fatalf("expected a blocking report for insufficient disk space, got:\n%s", report.String())
}
}
func TestCheckerRunCapturesAccountSnapshotWhenAdminURLSet(t *testing.T) {
counterPath := filepath.Join(t.TempDir(), "invocations")
binaryPath := writeFakeBinary(t, "0.15.5", counterPath)
configPath := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(configPath, []byte("[store.\"rocksdb\"]\ntype = \"rocksdb\"\n"), 0o644); err != nil {
t.Fatal(err)
}
dataDir := t.TempDir()
if err := os.WriteFile(filepath.Join(dataDir, "db"), make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
withFakeGithub(t, func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(Release{TagName: "v0.16.14"})
})
// A fake admin server: answers Ping's session-discovery GET, the
// impersonated session-discovery GET MailboxSnapshot makes for
// [email protected], and the POST /api calls for x:Account/query,
// x:Account/get, and Mailbox/get.
var apiURL string
adminSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap":
user, _, _ := r.BasicAuth()
if strings.Contains(user, "%") {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-alice"},
})
return
}
// A 0.16-era instance: the urn:stalwart:jmap capability is what
// says its management API is the JMAP one.
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"capabilities": map[string]any{"urn:ietf:params:jmap:core": map[string]any{}, "urn:stalwart:jmap": map[string]any{}},
})
case r.Method == http.MethodPost && r.URL.Path == "/api":
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
name := methodCalls[0].([]any)[0].(string)
switch name {
case "x:Account/query":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
}}, "g"},
}})
case "Mailbox/get":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"Mailbox/get", map[string]any{"list": []map[string]any{
{"name": "Inbox", "totalEmails": 5},
}}, "m"},
}})
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
apiURL = adminSrv.URL + "/api"
defer adminSrv.Close()
store := checkpoint.NewStore(t.TempDir())
rs, err := store.Create("", "latest")
if err != nil {
t.Fatal(err)
}
checker := New(Options{
BinaryPath: binaryPath, ConfigPath: configPath, DataDir: dataDir, TargetVersion: "latest",
AdminURL: adminSrv.URL, AdminUser: "admin", AdminPassword: "hunter2",
})
report, err := checker.Run(context.Background(), store, rs)
if err != nil {
t.Fatalf("Run: %v", err)
}
if report.Blocking() {
t.Fatalf("unexpected blocking report:\n%s", report.String())
}
if rs.PreflightSnapshot == nil {
t.Fatal("PreflightSnapshot should be populated when AdminURL is set")
}
if rs.PreflightSnapshot.AccountCount != 1 {
t.Errorf("AccountCount = %d, want 1", rs.PreflightSnapshot.AccountCount)
}
if len(rs.PreflightSnapshot.Domains) != 1 || rs.PreflightSnapshot.Domains[0] != "example.com" {
t.Errorf("Domains = %v, want [example.com]", rs.PreflightSnapshot.Domains)
}
aliceMailboxes := rs.PreflightSnapshot.MailboxCounts["[email protected]"]
if len(aliceMailboxes) != 1 || aliceMailboxes[0].Mailbox != "Inbox" || aliceMailboxes[0].Messages != 5 {
t.Errorf("alice's mailbox counts = %+v, want [{Inbox 5}]", aliceMailboxes)
}
// Resume: the snapshot must survive a reload from disk without
// re-running the check (the admin server would still work, but this
// confirms the persisted value is what's actually being relied on).
resumed, err := store.Load(rs.RunID)
if err != nil {
t.Fatalf("store.Load: %v", err)
}
if resumed.PreflightSnapshot == nil || resumed.PreflightSnapshot.AccountCount != 1 {
t.Errorf("resumed PreflightSnapshot = %+v, want AccountCount 1", resumed.PreflightSnapshot)
}
}