Files
stalwart-migrator/internal/stalwartapi/principal_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

335 lines
12 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package stalwartapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// stalwart015Server stands in for a 0.15.x instance: no urn:stalwart:jmap
// capability, POST /api is 404, and the management API is REST at
// /api/principal with 1-based page/limit paging. Every shape here was
// confirmed against a live 0.15.5 server.
func stalwart015Server(t *testing.T, individuals, domains []map[string]any) (*httptest.Server, *[]string) {
t.Helper()
var paths []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path+"?"+r.URL.RawQuery)
if r.URL.Path == "/.well-known/jmap" {
// 0.15.5 advertises no urn:stalwart:jmap.
json.NewEncoder(w).Encode(map[string]any{
"capabilities": map[string]any{"urn:ietf:params:jmap:core": map[string]any{}},
})
return
}
if r.URL.Path == "/api" {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"status":404,"title":"Not Found"}`))
return
}
if r.URL.Path != "/api/principal" {
w.WriteHeader(http.StatusNotFound)
return
}
set := individuals
if r.URL.Query().Get("types") == "domain" {
set = domains
}
limit, page := 100, 1
fmt.Sscanf(r.URL.Query().Get("limit"), "%d", &limit)
fmt.Sscanf(r.URL.Query().Get("page"), "%d", &page)
start := (page - 1) * limit
end := start + limit
if start > len(set) {
start = len(set)
}
if end > len(set) {
end = len(set)
}
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{"items": set[start:end], "total": len(set)},
})
}))
t.Cleanup(srv.Close)
return srv, &paths
}
// The headline case: against the version this tool actually migrates from,
// AccountSnapshot must not fall over on a 404 from the 0.16-only endpoint.
func TestAccountSnapshotUsesRESTAgainst015(t *testing.T) {
srv, paths := stalwart015Server(t,
[]map[string]any{
{"id": 4, "type": "individual", "name": "alice", "emails": []string{"[email protected]"}, "usedQuota": 9207},
{"id": 5, "type": "individual", "name": "bob", "emails": []string{"[email protected]"}, "usedQuota": 5380},
},
[]map[string]any{{"id": 1, "type": "domain", "name": "smoke.test"}},
)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatalf("AccountSnapshot against 0.15.x: %v", err)
}
if snap.AccountCount != 2 {
t.Errorf("AccountCount = %d, want 2", snap.AccountCount)
}
if len(snap.Domains) != 1 || snap.Domains[0] != "smoke.test" {
t.Errorf("Domains = %v, want [smoke.test]", snap.Domains)
}
if snap.UsedQuota["[email protected]"] != 9207 || snap.UsedQuota["[email protected]"] != 5380 {
t.Errorf("UsedQuota = %v, want alice 9207 and bob 5380", snap.UsedQuota)
}
// Message counts genuinely cannot be had from 0.15.x - see principal.go.
if len(snap.MailboxCounts) != 0 {
t.Errorf("MailboxCounts = %v, want empty: 0.15.x exposes no per-mailbox counts", snap.MailboxCounts)
}
for _, p := range *paths {
if strings.HasPrefix(p, "/api?") {
t.Errorf("the 0.16-only JMAP management endpoint was called against a 0.15.x instance: %s", p)
}
}
}
// A truncated "before" snapshot would make the post-migration comparison
// assert less than it claims, silently.
func TestRESTPrincipalsFollowsPagination(t *testing.T) {
var many []map[string]any
for i := 0; i < 250; i++ {
many = append(many, map[string]any{
"id": i, "type": "individual",
"name": fmt.Sprintf("user%03d", i),
"emails": []string{fmt.Sprintf("user%[email protected]", i)},
})
}
srv, _ := stalwart015Server(t, many, nil)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if snap.AccountCount != 250 {
t.Errorf("AccountCount = %d, want all 250 across pages", snap.AccountCount)
}
}
// An instance with no explicit domain principals still has domains, implied
// by its accounts' addresses.
func TestRESTSnapshotDerivesDomainsFromAddresses(t *testing.T) {
srv, _ := stalwart015Server(t,
[]map[string]any{
{"id": 1, "type": "individual", "name": "a", "emails": []string{"[email protected]"}},
{"id": 2, "type": "individual", "name": "b", "emails": []string{"[email protected]"}},
}, nil)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(snap.Domains) != 2 || snap.Domains[0] != "one.example" || snap.Domains[1] != "two.example" {
t.Errorf("Domains = %v, want [one.example two.example] sorted", snap.Domains)
}
}
func TestRESTSnapshotSurfacesAuthFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/jmap" {
json.NewEncoder(w).Encode(map[string]any{"capabilities": map[string]any{}})
return
}
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("want an error when the principal list rejects the credentials")
}
if !strings.Contains(err.Error(), "401") {
t.Errorf("error %q should carry the status it got", err)
}
}
// 0.16 reports the same per-account measure under a different name; both
// have to land in the same field or the comparison can't span the boundary.
func TestJMAPSnapshotCapturesUsedDiskQuota(t *testing.T) {
srv, _ := accountManagementAndMailboxServer(t, map[string][]map[string]any{
"[email protected]": {{"name": "Inbox", "totalEmails": 10}},
"[email protected]": {{"name": "Inbox", "totalEmails": 3}},
})
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(snap.UsedQuota) != 2 {
t.Errorf("UsedQuota = %v, want an entry per account", snap.UsedQuota)
}
}
// A fully configured, serving Stalwart 0.16.14 returns 404 for /api - the
// endpoint this client used to assume. It advertises its JMAP endpoint in
// the session document instead. This test pins the discovery so the
// assumption can't creep back.
func TestCallDiscoversTheEndpointRatherThanAssumingSlashApi(t *testing.T) {
var posted []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": "/jmap/",
"capabilities": map[string]any{"urn:stalwart:jmap": map[string]any{}},
})
return
}
posted = append(posted, r.URL.Path)
if r.URL.Path != "/jmap/" {
// Stand in for 0.16.14, which 404s anything else.
w.WriteHeader(http.StatusNotFound)
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"}
if _, err := client.AccountIDs(context.Background()); err != nil {
t.Fatalf("AccountIDs against an instance that only serves /jmap/: %v", err)
}
for _, p := range posted {
if p == "/api" {
t.Error("posted to /api, which 0.16.14 does not serve")
}
}
if len(posted) == 0 || posted[0] != "/jmap/" {
t.Errorf("posted to %v, want the advertised /jmap/", posted)
}
}
// A real instance advertises its canonical public URL, which routinely
// isn't reachable from where this tool runs - a hostname that may not
// resolve, over TLS that may not validate. Observed on a migrated
// instance: "https://mail.smoke.test/jmap/" while the operator reached it
// as http://127.0.0.1:8090. The path is the session's to dictate; the host
// is the operator's.
func TestEndpointKeepsTheOperatorsHostAndTheSessionsPath(t *testing.T) {
client := &Client{BaseURL: "http://127.0.0.1:8090"}
got, err := client.rebaseOntoBaseURL("https://mail.smoke.test/jmap/")
if err != nil {
t.Fatal(err)
}
if got != "http://127.0.0.1:8090/jmap/" {
t.Errorf("endpoint = %q, want the advertised path on the operator's host", got)
}
}
func TestEndpointRefusesASessionWithNoAPIURL(t *testing.T) {
client := &Client{BaseURL: "http://127.0.0.1:8090"}
if _, err := client.rebaseOntoBaseURL(""); err == nil {
t.Fatal("want an error when the instance advertises no apiUrl")
}
}
// Observed on a freshly migrated 0.16.14: an account that held the admin
// role before the migration was refused x:Account/query afterwards. A bare
// "forbidden" leaves an operator with nowhere to start.
func TestForbiddenExplainsThePostMigrationPermissionTrap(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 r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
json.NewEncoder(w).Encode(map[string]any{"apiUrl": "/jmap/"})
return
}
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"error", map[string]any{"type": "forbidden", "description": "You are not authorized to perform this action"}, "q"},
}})
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "sysadmin", Password: "x"}
_, err := client.AccountSnapshot(context.Background())
if err == nil {
t.Fatal("want an error for a forbidden management call")
}
for _, want := range []string{"forbidden", "admin role", "not permitted"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q should mention %q", err, want)
}
}
}
// x:Account.domainId is an internal id on v0.16, while a v0.15 snapshot
// records domain names. Comparing the two directly reported every domain as
// missing - a false alarm on the check meant to prove nothing was lost.
func TestAccountSnapshotResolvesDomainIdsToNames(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)
return
}
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
if strings.Contains(user, "%") {
w.WriteHeader(http.StatusForbidden) // no impersonation here
return
}
json.NewEncoder(w).Encode(map[string]any{"apiUrl": "/jmap/"})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
name := body["methodCalls"].([]any)[0].([]any)[0].(string)
switch name {
case "x:Domain/query":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Domain/query", map[string]any{"ids": []string{"b"}}, "q"},
[]any{"x:Domain/get", map[string]any{"list": []map[string]any{
{"id": "b", "name": "smoke.test"},
}}, "g"},
}})
case "x:Account/query":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"e"}}, "q"},
}})
case "x:Account/get":
json.NewEncoder(w).Encode(jmapEnvelope{MethodResponses: []any{
// domainId is the id, exactly as a real 0.16.14 returns it.
[]any{"x:Account/get", map[string]any{"list": []map[string]any{
{"id": "e", "name": "[email protected]", "domainId": "b", "usedDiskQuota": 9207},
}}, "g"},
}})
default:
t.Errorf("unexpected method call %s", name)
}
}))
defer srv.Close()
client := &Client{BaseURL: srv.URL, Username: "sysadmin", Password: "x"}
snap, err := client.AccountSnapshot(context.Background())
if err != nil {
t.Fatal(err)
}
if len(snap.Domains) != 1 || snap.Domains[0] != "smoke.test" {
t.Errorf("Domains = %v, want [smoke.test] - ids must be resolved or every domain reads as missing", snap.Domains)
}
}