GPLv3's "How to Apply These Terms" asks for a notice in each source file; this is the modern two-line SPDX form of it rather than the full paragraph. 82 files, including tests. The blank line after the header is load-bearing. In Go a comment block immediately preceding `package X` becomes the package doc comment, so without the separator the SPDX lines would be absorbed into the doc for the eleven packages whose doc.go (or main.go) opens with one, and `go doc` would print them. Verified it doesn't.
169 lines
6.0 KiB
Go
169 lines
6.0 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.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
|
|
user, _, _ := r.BasicAuth()
|
|
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)
|
|
}
|
|
}
|