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.
249 lines
9.4 KiB
Go
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)
|
|
}
|
|
}
|