The repositories moved off LINUXexpert-org. Here that is not a documentation change: the old organisation was the module path, so it is declared in go.mod and repeated in every internal import. Leaving it would have been worse than a stale link. GitHub redirects the repository, but a go.mod whose module line disagrees with the path it was fetched from is an error rather than a redirect, so `go get` on the new address would have failed against the old declaration. go.mod, 34 files of imports, and the repository links in README and ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved, only our own path.
256 lines
9.7 KiB
Go
256 lines
9.7 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package validate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
|
|
"github.com/Coffey-Labs/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:Domain/query":
|
|
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
|
|
[]any{"x:Domain/query", map[string]any{"ids": []string{"d1"}}, "q"},
|
|
[]any{"x:Domain/get", map[string]any{"list": []map[string]any{
|
|
{"id": "d1", "name": "smoke.test"},
|
|
}}, "g"},
|
|
}})
|
|
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)
|
|
}
|
|
}
|