Files
stalwart-migrator/internal/validate/content_integrity_test.go
T
jcoffey-dev 3e155fa42c Fix three defects a full VM migration exposed
Ran a complete 0.15.5 -> 0.16.14 migration of the smoke VM, driving the
phases in the order the real pipeline will. It worked - all mail intact and
readable afterwards, all ten listeners up, cutover executed for the first
time ever and checkpoint resume exercised - and it exposed three defects.

1. The converted config was installed root-owned while the service runs as
   its own user. Stalwart crash-looped 28 times on "Failed to read data
   store settings: Permission denied", minutes after the mistake and
   nowhere near it. This is the same ownership trap that retired the
   rollback implementation, in a new place: writing files as root is the
   natural thing for a tool running as root to do, and it is wrong every
   time the service is not root.

   Cutover now installs the config itself, copying ownership and mode from
   the config being replaced.

2. v0.16.14 does not serve /api - the endpoint stalwartapi assumed.
   Confirmed against a fully migrated, fully configured, serving instance
   rather than a sandbox: /api, /api/principal and /jmap/ all 404. The JMAP
   endpoint is the one the session document advertises, which is what RFC
   8620 discovery is for.

   The client now discovers it, re-basing the advertised path onto the
   operator's host: a real instance advertises its canonical public URL
   ("https://mail.smoke.test/jmap/") which frequently isn't reachable from
   where this tool runs. The session is authoritative about the path; the
   operator is authoritative about the host.

3. Dispatching on the urn:stalwart:jmap capability was wrong, because
   NEITHER version advertises it - not 0.15.5, and not a fully migrated
   0.16.14. That sent 0.16 instances down the 0.15 REST path where every
   call 404s. The client probes what the instance actually serves instead.
   Less elegant than a declared capability, with the advantage of being
   true.

Also: a JMAP "forbidden" now explains itself. An account holding the admin
role before the migration was refused x:Account/query afterwards, and a
bare "forbidden" gives an operator nowhere to start. Whether the role
failed to carry or v0.16 wants different permissions was not isolated, and
that question is recorded as open - it gates quota recalculation and any
post-migration validation.

Verified against both live instances: the 0.15.5 reports 3 accounts and its
domain over REST, and the migrated 0.16.14 routes to JMAP, finds the right
endpoint, and returns the explained refusal.
2026-08-23 21:32:30 -07:00

249 lines
9.4 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package validate
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/LINUXexpert-org/stalwart-migrator/internal/checkpoint"
"github.com/LINUXexpert-org/stalwart-migrator/internal/stalwartapi"
)
// jmapEnvelope mirrors the wire shape stalwartapi.Client.call() parses.
type jmapEnvelope struct {
MethodResponses []any `json:"methodResponses"`
}
// fakeManagementServer serves x:Account/query + x:Account/get from
// accounts, and, for each of them, session discovery + Mailbox/get from
// mailboxesByEmail (keyed by the account's post-migration email).
func fakeManagementServer(t *testing.T, accounts []map[string]any, mailboxesByEmail map[string][]map[string]any) *httptest.Server {
t.Helper()
var apiURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/principal" {
w.WriteHeader(http.StatusNotFound) // v0.16 shape: no REST management API
return
}
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
if !strings.Contains(user, "%") {
// This instance is a migrated 0.16 one, which is what the
// urn:stalwart:jmap capability says.
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{}},
})
return
}
target := strings.SplitN(user, "%", 2)[0]
if _, ok := mailboxesByEmail[target]; !ok {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{"urn:ietf:params:jmap:mail": "mail-" + target},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
call := methodCalls[0].([]any)
name := call[0].(string)
switch name {
case "x:Account/query":
ids := make([]string, len(accounts))
for i, a := range accounts {
ids[i] = a["id"].(string)
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": ids}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/get", map[string]any{"list": accounts}, "g"},
}})
case "Mailbox/get":
args := call[1].(map[string]any)
accountID := args["accountId"].(string)
target := strings.TrimPrefix(accountID, "mail-")
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"Mailbox/get", map[string]any{"list": mailboxesByEmail[target]}, "m"},
}})
}
}))
apiURL = srv.URL + "/api"
return srv
}
func TestCompareContentIntegrityMatchesRewrittenBareUsernameByLocalPart(t *testing.T) {
// Pre-migration, the account was a bare username "alice" (pre-0.16
// style). Post-migration, v0.16's own conversion rewrote it to a full
// email address - see UPGRADING/v0_16.md. An exact-string match would
// wrongly report "alice" as missing.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 42}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}}, // bare username, pre-migration
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if !result.OK() {
t.Errorf("result.OK() = false, want true (local-part match should have found [email protected]): %s", result.String())
}
if len(result.MissingAccounts) != 0 {
t.Errorf("MissingAccounts = %v, want none", result.MissingAccounts)
}
}
func TestCompareContentIntegrityNoFalseMatchAcrossUnrelatedAccounts(t *testing.T) {
// "alice" (before) must not spuriously match "[email protected]"
// (after) just because one contains the other - local-part comparison
// must be an exact match on the part before "@", not a substring check.
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.com"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 1}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"alice": {{Mailbox: "Inbox", Messages: 42}},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.OK() {
t.Fatal("result.OK() = true, want a missing-account failure - [email protected] is a different account than alice")
}
if len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "alice" {
t.Errorf("MissingAccounts = %v, want [alice]", result.MissingAccounts)
}
}
func TestCompareContentIntegrityMultipleMailboxesPerAccount(t *testing.T) {
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "example.org"}},
map[string][]map[string]any{"[email protected]": {
{"name": "Inbox", "totalEmails": 10},
{"name": "Archive", "totalEmails": 200},
}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
MailboxCounts: map[string][]checkpoint.MailboxCount{
"[email protected]": {
{Mailbox: "Inbox", Messages: 10},
{Mailbox: "Archive", Messages: 199}, // one message short
},
},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatalf("compareContentIntegrity: %v", err)
}
if result.AccountsChecked != 1 || result.MailboxesChecked != 2 {
t.Errorf("AccountsChecked=%d MailboxesChecked=%d, want 1 and 2", result.AccountsChecked, result.MailboxesChecked)
}
if len(result.MessageCountMismatches) != 1 {
t.Fatalf("MessageCountMismatches = %+v, want exactly one (Archive)", result.MessageCountMismatches)
}
m := result.MessageCountMismatches[0]
if m.Mailbox != "Archive" || m.Before != 199 || m.After != 200 {
t.Errorf("mismatch = %+v, want Archive 199->200", m)
}
}
// The bug this guards against was found by running preflight against a real
// Stalwart 0.15.5: it reports no per-mailbox counts, so the "before"
// snapshot has none, and the comparison used to iterate that empty map,
// check nothing, and report "all message counts match" - the strongest
// claim this tool makes, made vacuously.
func TestCompareContentIntegrityDoesNotClaimCountsMatchWhenSourceHadNone(t *testing.T) {
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "smoke.test"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 3}}},
)
defer srv.Close()
// A 0.15.x-shaped snapshot: accounts and used-quota, no mailbox counts.
before := &checkpoint.PreflightSnapshot{
AccountCount: 1,
Domains: []string{"smoke.test"},
UsedQuota: map[string]int64{"[email protected]": 9207},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatal(err)
}
if result.MessageCountsCompared {
t.Error("MessageCountsCompared = true, but the source snapshot had no counts")
}
if result.AccountsChecked != 1 {
t.Errorf("AccountsChecked = %d, want 1 - the account set must still be verified", result.AccountsChecked)
}
if strings.Contains(result.String(), "all message counts match") {
t.Errorf("report claims counts match when none were compared:\n%s", result)
}
if !strings.Contains(result.String(), "MESSAGE COUNTS NOT COMPARED") {
t.Errorf("report must say plainly that no-data-loss was not verified:\n%s", result)
}
}
// Presence checking still has to work on that path, or it would be no
// better than the vacuous pass it replaced.
func TestCompareContentIntegrityDetectsLostAccountWithoutCounts(t *testing.T) {
srv := fakeManagementServer(t,
[]map[string]any{{"id": "a1", "name": "[email protected]", "domainId": "smoke.test"}},
map[string][]map[string]any{"[email protected]": {{"name": "Inbox", "totalEmails": 3}}},
)
defer srv.Close()
before := &checkpoint.PreflightSnapshot{
AccountCount: 2,
Domains: []string{"smoke.test", "gone.example"},
UsedQuota: map[string]int64{"[email protected]": 9207, "[email protected]": 5380},
}
client := &stalwartapi.Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
result, err := compareContentIntegrity(context.Background(), client, before)
if err != nil {
t.Fatal(err)
}
if result.OK() {
t.Fatalf("want a failing result when an account and a domain vanished:\n%s", result)
}
if len(result.MissingAccounts) != 1 || result.MissingAccounts[0] != "[email protected]" {
t.Errorf("MissingAccounts = %v, want [[email protected]]", result.MissingAccounts)
}
if len(result.MissingDomains) != 1 || result.MissingDomains[0] != "gone.example" {
t.Errorf("MissingDomains = %v, want [gone.example]", result.MissingDomains)
}
}