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.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user