Files
stalwart-migrator/internal/stalwartapi/management_test.go
T
jcoffey-dev 719a945d64 Initial commit: stalwart-migrator design and scaffolding
In-place upgrade tool for Stalwart Mail Server (0.15.5 -> latest) with
checkpointed rollback and post-migration validation. Design stage; see
ARCHITECTURE.md.
2026-08-22 18:17:17 -07:00

202 lines
7.4 KiB
Go

package stalwartapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// 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) {
gotPaths = append(gotPaths, r.URL.Path)
if r.Method == http.MethodGet && r.URL.Path == "/.well-known/jmap" {
user, _, _ := r.BasicAuth()
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: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) {
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) {
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)
}
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)
}
}