GPLv3's "How to Apply These Terms" asks for a notice in each source file; this is the modern two-line SPDX form of it rather than the full paragraph. 82 files, including tests. The blank line after the header is load-bearing. In Go a comment block immediately preceding `package X` becomes the package doc comment, so without the separator the SPDX lines would be absorbed into the doc for the eleven packages whose doc.go (or main.go) opens with one, and `go doc` would print them. Verified it doesn't.
205 lines
7.5 KiB
Go
205 lines
7.5 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"
|
|
"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)
|
|
}
|
|
}
|