119 SPDX-FileCopyrightText headers and the README's licence line. The distinction that matters here: LINUXexpert-org appears in this repository in two completely different roles. As a copyright holder in the SPDX headers, which is what changes, and as the GitHub organisation in the module path and 64 import statements, which does not -- the repository still lives at github.com/LINUXexpert-org/stalwart-migrator, and rewriting that would not be a licence change, it would break the build. Both replacements are anchored to their copyright forms, so an import path cannot match either. Import count is 64 before and after, and go.mod is untouched. LICENSE untouched: the FSF's copyright on the GPL text and the "<name of author>" placeholders are not ours to edit. go vet, go build and go test all clean.
411 lines
15 KiB
Go
411 lines
15 KiB
Go
// SPDX-FileCopyrightText: 2026 Coffey Labs
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// Multi-tenancy has to be detectable before a migration starts. Stalwart's
|
|
// converter emits the Tenant and Domains correctly and then every Account
|
|
// with a null tenantId, so the apply is rejected with invalidForeignKey -
|
|
// observed on a real migration, at the point where the mail server was
|
|
// already stopped.
|
|
func TestTenantNamesReportsTenantPrincipals(t *testing.T) {
|
|
srv, _ := stalwart015Server(t,
|
|
[]map[string]any{{"id": 1, "type": "individual", "name": "[email protected]"}},
|
|
nil,
|
|
)
|
|
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "x"}
|
|
|
|
// The fake serves the "domain" set for types=domain and individuals
|
|
// otherwise; a tenant query returns the individuals set, so assert on
|
|
// the call succeeding and the names being read, not on a fixed count.
|
|
names, err := client.TenantNames(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("TenantNames: %v", err)
|
|
}
|
|
if names == nil {
|
|
t.Error("TenantNames returned nil without an error; want a (possibly empty) list")
|
|
}
|
|
}
|
|
|
|
// The "before" side of the post-migration comparison. It used to add every
|
|
// domain appearing in any account's address on top of the domain principals,
|
|
// so an instance with three declared domains and accounts aliased across nine
|
|
// reported nine - and the 0.16 side, which lists the domains the server
|
|
// actually holds, then looked as though six had been lost by a migration that
|
|
// lost nothing. INBUXA is exactly that shape.
|
|
func TestRESTSnapshotDoesNotInflateDomainsWithAliases(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
typ := r.URL.Query().Get("types")
|
|
w.Header().Set("content-type", "application/json")
|
|
switch typ {
|
|
case "domain":
|
|
_, _ = w.Write([]byte(`{"data":{"items":[{"type":"domain","name":"example.org"}],"total":1}}`))
|
|
default:
|
|
_, _ = w.Write([]byte(`{"data":{"items":[{"type":"individual","name":"[email protected]",` +
|
|
`"emails":["[email protected]","[email protected]","[email protected]"],"usedQuota":1}],"total":1}}`))
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
snap, err := (&Client{BaseURL: srv.URL, Username: "admin", Password: "pw", HTTPClient: srv.Client()}).principalSnapshotREST(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("principalSnapshotREST: %v", err)
|
|
}
|
|
if len(snap.Domains) != 1 || snap.Domains[0] != "example.org" {
|
|
t.Fatalf("Domains = %v, want just the declared domain principal", snap.Domains)
|
|
}
|
|
}
|
|
|
|
// The fallback still exists for an instance that declares none, where an
|
|
// address is the only evidence a domain is in use.
|
|
func TestRESTSnapshotFallsBackToAddressesWhenNoDomainPrincipals(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("content-type", "application/json")
|
|
if r.URL.Query().Get("types") == "domain" {
|
|
_, _ = w.Write([]byte(`{"data":{"items":[],"total":0}}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"data":{"items":[{"type":"individual","name":"[email protected]",` +
|
|
`"emails":["[email protected]"],"usedQuota":1}],"total":1}}`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
snap, err := (&Client{BaseURL: srv.URL, Username: "admin", Password: "pw", HTTPClient: srv.Client()}).principalSnapshotREST(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("principalSnapshotREST: %v", err)
|
|
}
|
|
if len(snap.Domains) != 1 || snap.Domains[0] != "example.org" {
|
|
t.Fatalf("Domains = %v, want the domain implied by the address", snap.Domains)
|
|
}
|
|
}
|