Files
stalwart-migrator/internal/stalwartapi/principal_test.go
T
jcoffey-dev 3e155fa42c Fix three defects a full VM migration exposed
Ran a complete 0.15.5 -> 0.16.14 migration of the smoke VM, driving the
phases in the order the real pipeline will. It worked - all mail intact and
readable afterwards, all ten listeners up, cutover executed for the first
time ever and checkpoint resume exercised - and it exposed three defects.

1. The converted config was installed root-owned while the service runs as
   its own user. Stalwart crash-looped 28 times on "Failed to read data
   store settings: Permission denied", minutes after the mistake and
   nowhere near it. This is the same ownership trap that retired the
   rollback implementation, in a new place: writing files as root is the
   natural thing for a tool running as root to do, and it is wrong every
   time the service is not root.

   Cutover now installs the config itself, copying ownership and mode from
   the config being replaced.

2. v0.16.14 does not serve /api - the endpoint stalwartapi assumed.
   Confirmed against a fully migrated, fully configured, serving instance
   rather than a sandbox: /api, /api/principal and /jmap/ all 404. The JMAP
   endpoint is the one the session document advertises, which is what RFC
   8620 discovery is for.

   The client now discovers it, re-basing the advertised path onto the
   operator's host: a real instance advertises its canonical public URL
   ("https://mail.smoke.test/jmap/") which frequently isn't reachable from
   where this tool runs. The session is authoritative about the path; the
   operator is authoritative about the host.

3. Dispatching on the urn:stalwart:jmap capability was wrong, because
   NEITHER version advertises it - not 0.15.5, and not a fully migrated
   0.16.14. That sent 0.16 instances down the 0.15 REST path where every
   call 404s. The client probes what the instance actually serves instead.
   Less elegant than a declared capability, with the advantage of being
   true.

Also: a JMAP "forbidden" now explains itself. An account holding the admin
role before the migration was refused x:Account/query afterwards, and a
bare "forbidden" gives an operator nowhere to start. Whether the role
failed to carry or v0.16 wants different permissions was not isolated, and
that question is recorded as open - it gates quota recalculation and any
post-migration validation.

Verified against both live instances: the 0.15.5 reports 3 accounts and its
domain over REST, and the migrated 0.16.14 routes to JMAP, finds the right
endpoint, and returns the explained refusal.
2026-08-23 21:32:30 -07:00

279 lines
9.7 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)
}
}
}