Files
stalwart-migrator/internal/stalwartapi/management_test.go
T
jcoffey-dev 1684c88877 Resolve v0.16 domain ids to names before comparing directories
x:Account.domainId is an internal id on v0.16 ("b"), not a domain name. A
pre-migration snapshot taken from a v0.15 instance records names
("smoke.test"), so the post-migration directory comparison compared ids
against names and would have reported every domain as having vanished -
a false alarm on the check whose whole job is proving nothing was lost.

The client now resolves them with x:Domain/query + x:Domain/get in a single
request, using a JMAP back-reference (RFC 8620 3.7). Confirmed against a
live 0.16.14 before being written:

    ["x:Domain/get", {"list":[{"name":"smoke.test","id":"b"}]}, "g"]

An id that can't be resolved is kept as-is - a domain that can't be named is
still a domain that exists - but a failure of the resolution call itself is
an error rather than a silent fallback, since quietly comparing ids against
names is precisely the bug being fixed.

Verified against the live migrated instance: the snapshot that reported
domains=[b] now reports domains=[smoke.test], matching what the
pre-migration snapshot recorded.
2026-08-23 21:45:55 -07:00

250 lines
9.0 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package stalwartapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// serveJMAPSession answers session discovery for a server standing in for
// a 0.16+ instance: what marks it as one is the urn:stalwart:jmap
// capability, which is exactly what AccountSnapshot dispatches on. Returns
// true if it handled the request.
func serveJMAPSession(w http.ResponseWriter, r *http.Request, apiURL string) bool {
if r.Method != http.MethodGet || r.URL.Path != "/.well-known/jmap" {
return false
}
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 true
}
// jmapEnvelope mirrors the wire shape this package's call() parses: a
// top-level {"methodResponses": [...]} object where each entry is a
// [name, args, callId] triple (RFC 8620 §3.2).
type jmapEnvelope struct {
MethodResponses []any `json:"methodResponses"`
}
// accountManagementAndMailboxServer builds a fake server that answers both
// the x:Account/* management calls AccountSnapshot makes directly, and the
// session-discovery + Mailbox/get calls it makes indirectly (per account)
// via MailboxSnapshot. mailboxesFor maps an account email to the mailbox
// list its Mailbox/get should return; an account absent from the map gets a
// 403 on session discovery, simulating a missing `impersonate` grant.
func accountManagementAndMailboxServer(t *testing.T, mailboxesFor map[string][]map[string]any) (*httptest.Server, *[]string) {
t.Helper()
var gotPaths []string
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
}
gotPaths = append(gotPaths, r.URL.Path)
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
if !strings.Contains(user, "%") {
// The client's own session request, used to decide which
// management API this instance speaks.
serveJMAPSession(w, r, apiURL)
return
}
target := strings.SplitN(user, "%", 2)[0]
if _, ok := mailboxesFor[target]; !ok {
w.WriteHeader(http.StatusForbidden)
return
}
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": apiURL,
"primaryAccounts": map[string]string{jmapMailCapability: "mail-" + target},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
methodCalls := body["methodCalls"].([]any)
first := methodCalls[0].([]any)
methodName := first[0].(string)
switch methodName {
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": "example.com"},
}}, "g"},
}})
case "x:Account/query":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1", "a2"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "a1", "name": "[email protected]", "domainId": "example.com"},
{"id": "a2", "name": "[email protected]", "domainId": "example.org"},
}}, "g"},
}})
case "Mailbox/get":
args := first[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": mailboxesFor[target]}, "m"},
}})
default:
t.Errorf("unexpected method call: %s", methodName)
}
}))
apiURL = srv.URL + "/api"
return srv, &gotPaths
}
func TestAccountSnapshotQueriesThenGets(t *testing.T) {
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
"[email protected]": {{"name": "Inbox", "totalEmails": 3}, {"name": "Archive", "totalEmails": 100}},
})
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
if snap.AccountCount != 2 {
t.Errorf("AccountCount = %d, want 2", snap.AccountCount)
}
if len(snap.Domains) != 2 || snap.Domains[0] != "example.com" || snap.Domains[1] != "example.org" {
t.Errorf("Domains = %v, want [example.com example.org] (sorted)", snap.Domains)
}
if len(snap.MailboxErrors) != 0 {
t.Errorf("MailboxErrors = %v, want none (both accounts should succeed)", snap.MailboxErrors)
}
alice := snap.MailboxCounts["[email protected]"]
if len(alice) != 1 || alice[0].Mailbox != "Inbox" || alice[0].Messages != 10 {
t.Errorf("alice's mailboxes = %+v, want [{Inbox 10}]", alice)
}
bob := snap.MailboxCounts["[email protected]"]
if len(bob) != 2 || bob[1].Mailbox != "Archive" || bob[1].Messages != 100 {
t.Errorf("bob's mailboxes = %+v, want Inbox then Archive(100)", bob)
}
}
func TestAccountSnapshotRecordsPerAccountMailboxFailureWithoutFailingOverall(t *testing.T) {
// [email protected] is deliberately absent from mailboxesFor, simulating
// a missing `impersonate` grant for that one account.
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
})
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot should not fail overall just because one account's mailbox capture failed: %v", err)
}
if snap.AccountCount != 2 {
t.Errorf("AccountCount = %d, want 2 (account enumeration is unaffected by the mailbox-capture failure)", snap.AccountCount)
}
if _, ok := snap.MailboxCounts["[email protected]"]; !ok {
t.Error("alice's mailbox counts should still be captured")
}
if _, ok := snap.MailboxCounts["[email protected]"]; ok {
t.Error("bob's mailbox counts should NOT be present - his capture failed")
}
if _, ok := snap.MailboxErrors["[email protected]"]; !ok {
t.Error("bob's failure should be recorded in MailboxErrors, not silently dropped")
}
}
func TestAccountSnapshotEmptyInstance(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if serveJMAPSession(w, r, "/api") {
return
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
if snap.AccountCount != 0 {
t.Errorf("AccountCount = %d, want 0", snap.AccountCount)
}
}
func TestAccountSnapshotPropagatesJMAPError(t *testing.T) {
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 serveJMAPSession(w, r, "/api") {
return
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"error", map[string]any{"type": "forbidden"}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
_, err := client.AccountSnapshot(context.Background())
if err == nil {
t.Fatal("AccountSnapshot should surface a JMAP-level error response")
}
}
func TestAccountSnapshotPropagatesHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("invalid credentials"))
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "wrong"}
_, err := client.AccountSnapshot(context.Background())
if err == nil {
t.Fatal("AccountSnapshot should error on a non-200 response")
}
}
func TestAccountSnapshotSendsBasicAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok || user != "admin" || pass != "hunter2" {
t.Errorf("BasicAuth = (%s, %s, %v), want (admin, hunter2, true)", user, pass, ok)
}
if serveJMAPSession(w, r, "/api") {
return
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{}}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
if _, err := client.AccountSnapshot(context.Background()); err != nil {
t.Fatalf("AccountSnapshot: %v", err)
}
}