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

296 lines
9.3 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"
"sync"
"testing"
"time"
)
// taskServer answers x:Task/set and x:Task/get. queue models Stalwart's own
// task queue: a task that has run is *removed* from it, which is how
// completion is detected (see task.go's opening comment).
type taskServer struct {
mu sync.Mutex
queue map[string]string // task id -> status @type
created []map[string]any // the create objects received, in request order
notFound bool // reject every creation
}
func newTaskServer(t *testing.T) (*taskServer, *httptest.Server) {
t.Helper()
ts := &taskServer{queue: map[string]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
}
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
// The endpoint is discovered, not assumed - see apiEndpoint.
json.NewEncoder(w).Encode(map[string]any{
"apiUrl": "/api",
"capabilities": map[string]any{"urn:stalwart:jmap": map[string]any{}},
})
return
}
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
call := body["methodCalls"].([]any)[0].([]any)
name := call[0].(string)
args := call[1].(map[string]any)
ts.mu.Lock()
defer ts.mu.Unlock()
switch name {
case "x:Task/set":
create := args["create"].(map[string]any)
created := map[string]any{}
notCreated := map[string]any{}
for creationID, obj := range create {
ts.created = append(ts.created, obj.(map[string]any))
if ts.notFound {
notCreated[creationID] = map[string]any{"type": "forbidden"}
continue
}
id := "task-" + creationID
ts.queue[id] = "Pending"
created[creationID] = map[string]any{"id": id}
}
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Task/set", map[string]any{"created": created, "notCreated": notCreated}, "s"},
}})
case "x:Task/get":
var list []any
for _, raw := range args["ids"].([]any) {
id := raw.(string)
status, stillQueued := ts.queue[id]
if !stillQueued {
continue // consumed: finished
}
entry := map[string]any{"id": id, "status": map[string]any{"@type": status}}
if status == "Failed" {
entry["status"].(map[string]any)["failureReason"] = "store unavailable"
}
list = append(list, entry)
}
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Task/get", map[string]any{"list": list}, "g"},
}})
case "x:Account/query":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1", "a2", "a3"}}, "q"},
}})
default:
t.Errorf("unexpected method call %s", name)
}
}))
t.Cleanup(srv.Close)
return ts, srv
}
func (ts *taskServer) finish(id string) {
ts.mu.Lock()
defer ts.mu.Unlock()
delete(ts.queue, id)
}
func (ts *taskServer) fail(id string) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.queue[id] = "Failed"
}
// The wire shape here comes from Stalwart's x:Task schema reference, so the
// test pins it: an AccountMaintenance variant with maintenanceType
// recalculateQuota, one per account, in a single Task/set call.
func TestCreateQuotaRecalculationTasksSendsOnePerAccount(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1", "a2"})
if err != nil {
t.Fatalf("CreateQuotaRecalculationTasks: %v", err)
}
if len(ids) != 2 {
t.Fatalf("created %d task ids, want 2: %v", len(ids), ids)
}
if len(ts.created) != 2 {
t.Fatalf("server received %d creations, want 2", len(ts.created))
}
seen := map[string]bool{}
for _, obj := range ts.created {
if obj["@type"] != "AccountMaintenance" {
t.Errorf("@type = %v, want AccountMaintenance", obj["@type"])
}
if obj["maintenanceType"] != "recalculateQuota" {
t.Errorf("maintenanceType = %v, want recalculateQuota", obj["maintenanceType"])
}
status := obj["status"].(map[string]any)
if status["@type"] != "Pending" {
t.Errorf("status.@type = %v, want Pending", status["@type"])
}
seen[obj["accountId"].(string)] = true
}
if !seen["a1"] || !seen["a2"] {
t.Errorf("accountIds sent = %v, want a1 and a2", seen)
}
}
func TestCreateTenantQuotaRecalculationTasksUsesTenantVariant(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
if _, err := client.CreateTenantQuotaRecalculationTasks(context.Background(), []string{"t1"}); err != nil {
t.Fatal(err)
}
if got := ts.created[0]["@type"]; got != "TenantMaintenance" {
t.Errorf("@type = %v, want TenantMaintenance", got)
}
if got := ts.created[0]["tenantId"]; got != "t1" {
t.Errorf("tenantId = %v, want t1", got)
}
}
// Reporting "quotas recalculated" for an account whose task the server
// refused would be exactly the silent partial success this tool exists to
// catch.
func TestCreateQuotaRecalculationTasksFailsOnRefusedCreations(t *testing.T) {
ts, srv := newTaskServer(t)
ts.notFound = true
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
_, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1"})
if err == nil {
t.Fatal("want error when the server refuses a creation, got nil")
}
if !strings.Contains(err.Error(), "account a1") {
t.Errorf("error %q should name the account whose task was refused", err)
}
}
func TestCreateQuotaRecalculationTasksIsANoOpForNoAccounts(t *testing.T) {
_, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), nil)
if err != nil || len(ids) != 0 {
t.Errorf("want no ids and no error for an empty account list, got %v, %v", ids, err)
}
}
func TestWaitForTasksReturnsOnceTheQueueDrains(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1", "a2"})
if err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(100 * time.Millisecond)
for _, id := range ids {
ts.finish(id)
}
}()
failures, err := client.WaitForTasks(context.Background(), ids, 10*time.Second)
if err != nil {
t.Fatalf("WaitForTasks: %v", err)
}
if len(failures) != 0 {
t.Errorf("failures = %v, want none", failures)
}
}
func TestWaitForTasksCollectsFailedTasksInsteadOfWaitingForever(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1", "a2"})
if err != nil {
t.Fatal(err)
}
ts.fail(ids[0])
ts.finish(ids[1])
failures, err := client.WaitForTasks(context.Background(), ids, 10*time.Second)
if err != nil {
t.Fatalf("a task reaching Failed is a result, not a polling error: %v", err)
}
if len(failures) != 1 || failures[0].TaskID != ids[0] {
t.Fatalf("failures = %v, want just %s", failures, ids[0])
}
if !strings.Contains(failures[0].Reason, "store unavailable") {
t.Errorf("failure reason = %q, want the server's own reason", failures[0].Reason)
}
}
// "Still running after the timeout" and "ran and failed" are different
// answers for an operator - one means wait longer, the other means
// something is wrong.
func TestWaitForTasksDistinguishesATimeoutFromAFailure(t *testing.T) {
_, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1"})
if err != nil {
t.Fatal(err)
}
failures, err := client.WaitForTasks(context.Background(), ids, 100*time.Millisecond)
if err == nil {
t.Fatal("want an error when tasks are still queued at the timeout, got nil")
}
if len(failures) != 0 {
t.Errorf("failures = %v, want none - a still-queued task hasn't failed", failures)
}
if !strings.Contains(err.Error(), "still queued") {
t.Errorf("error %q should say the tasks were still queued, not that they failed", err)
}
}
func TestAccountIDsSkipsTheMailboxWalk(t *testing.T) {
_, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.AccountIDs(context.Background())
if err != nil {
t.Fatal(err)
}
if len(ids) != 3 {
t.Errorf("AccountIDs = %v, want 3 ids", ids)
}
}
func TestWaitForPingReturnsOnceTheInstanceAnswers(t *testing.T) {
var mu sync.Mutex
up := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if !up {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(map[string]any{"apiUrl": "/api"})
}))
defer srv.Close()
go func() {
time.Sleep(100 * time.Millisecond)
mu.Lock()
up = true
mu.Unlock()
}()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
if err := client.WaitForPing(context.Background(), 10*time.Second); err != nil {
t.Fatalf("WaitForPing: %v", err)
}
}