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.
335 lines
12 KiB
Go
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)
|
|
}
|
|
}
|