Files
pulse/pkg/pmg/client_test.go
T
rcourtman 30fa3fd810 feat: add complete Proxmox Mail Gateway (PMG) monitoring support
Add comprehensive PMG monitoring with mail statistics, queue depth tracking,
spam distribution analysis, and quarantine monitoring. Includes full discovery
support and UI consistency improvements across all Proxmox products.

Backend:
- Add pkg/pmg package with complete API client for PMG operations
- Implement mail statistics collection (inbound/outbound, spam, virus, bounces)
- Add queue depth monitoring (active, deferred, hold, incoming queues)
- Support spam score distribution and quarantine totals
- Add PMG-specific discovery logic to differentiate from PVE on port 8006
- Extend mock data generator with realistic PMG instances and metrics
- Add PMG node configuration support in config system

Frontend:
- Create MailGateway.tsx component with detailed PMG dashboard
- Display mail flow statistics with time-series charts
- Show queue depth with color-coded warnings (>50 messages or >30min age)
- Add spam distribution histogram and quarantine status
- Support cluster node status with individual queue monitoring
- Add PMG to network discovery with purple branding and mail icon
- Implement conditional navigation (hide PMG tab when no instances configured)
- Standardize discovery UI controls across PVE/PBS/PMG settings pages

API:
- Add /api/config/pmg endpoints for node configuration
- Support PMG-specific monitoring toggles (mail stats, queues, quarantine)
- Extend system settings with PMG configuration options

Discovery:
- Detect PMG vs PVE on shared port 8006 using /api2/json/statistics/mail endpoint
- Return 'pmg' type for mail gateway servers in discovery results
- Update DiscoveryModal to display PMG servers with appropriate styling

This completes ecosystem monitoring support for all three Proxmox products:
Proxmox VE, Proxmox Backup Server, and Proxmox Mail Gateway.
2025-10-10 14:30:51 +00:00

234 lines
5.9 KiB
Go

package pmg
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNewClientAuthenticatesWithJSONPayload(t *testing.T) {
t.Parallel()
var authCalls int
var versionCalls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api2/json/access/ticket":
authCalls++
if r.Method != http.MethodPost {
t.Fatalf("expected POST for auth, got %s", r.Method)
}
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("expected JSON auth content-type, got %s", ct)
}
var payload map[string]string
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed decoding auth payload: %v", err)
}
if payload["username"] != "api@pmg" {
t.Fatalf("expected username api@pmg, got %s", payload["username"])
}
if payload["password"] != "secret" {
t.Fatalf("expected password secret, got %s", payload["password"])
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"ticket":"ticket123","CSRFPreventionToken":"csrf123"}}`)
case "/api2/json/version":
versionCalls++
cookie := r.Header.Get("Cookie")
if !strings.Contains(cookie, "PMGAuthCookie=ticket123") {
t.Fatalf("expected auth cookie, got %s", cookie)
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"version":"8.0.0","release":"1"}}`)
default:
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
}))
defer server.Close()
client, err := NewClient(ClientConfig{
Host: server.URL,
User: "api@pmg",
Password: "secret",
VerifySSL: false,
})
if err != nil {
t.Fatalf("unexpected error creating client: %v", err)
}
info, err := client.GetVersion(context.Background())
if err != nil {
t.Fatalf("get version failed: %v", err)
}
if info == nil || info.Version != "8.0.0" {
t.Fatalf("expected version 8.0.0, got %+v", info)
}
if authCalls != 1 {
t.Fatalf("expected one auth call, got %d", authCalls)
}
if versionCalls != 1 {
t.Fatalf("expected one version call, got %d", versionCalls)
}
}
func TestClientAuthenticateFallsBackToForm(t *testing.T) {
t.Parallel()
var authCalls int
var jsonAuthReceived bool
var formAuthReceived bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api2/json/access/ticket":
authCalls++
switch authCalls {
case 1:
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
t.Fatalf("first auth call should use JSON content-type, got %s", ct)
}
jsonAuthReceived = true
w.WriteHeader(http.StatusUnsupportedMediaType)
fmt.Fprint(w, "use form encoding")
case 2:
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
t.Fatalf("fallback auth should use form encoding, got %s", ct)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("failed reading form body: %v", err)
}
formValues := string(body)
if !strings.Contains(formValues, "username=api%40pmg") {
t.Fatalf("expected username in form body, got %s", formValues)
}
formAuthReceived = true
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"ticket":"ticket456","CSRFPreventionToken":"csrf456"}}`)
default:
t.Fatalf("unexpected auth attempt %d", authCalls)
}
case "/api2/json/version":
if !strings.Contains(r.Header.Get("Cookie"), "PMGAuthCookie=ticket456") {
t.Fatalf("expected fallback auth cookie, got %s", r.Header.Get("Cookie"))
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"version":"9.0.0"}}`)
default:
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
}))
defer server.Close()
client, err := NewClient(ClientConfig{
Host: server.URL,
User: "api@pmg",
Password: "secret",
VerifySSL: false,
})
if err != nil {
t.Fatalf("unexpected error creating client with fallback: %v", err)
}
if _, err := client.GetVersion(context.Background()); err != nil {
t.Fatalf("get version after fallback failed: %v", err)
}
if authCalls != 2 {
t.Fatalf("expected two auth attempts, got %d", authCalls)
}
if !jsonAuthReceived {
t.Fatal("expected JSON auth request to be received")
}
if !formAuthReceived {
t.Fatal("expected form-based auth fallback to be received")
}
}
func TestClientUsesTokenAuthorizationHeader(t *testing.T) {
t.Parallel()
var authHeader string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api2/json/statistics/mail":
if r.Header.Get("Content-Type") != "" {
t.Fatalf("expected no explicit content-type on GET, got %s", r.Header.Get("Content-Type"))
}
if r.Method != http.MethodGet {
t.Fatalf("unexpected method %s", r.Method)
}
authHeader = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"data":{"count":42}}`)
case "/api2/json/access/ticket":
t.Fatalf("token-based client should not request tickets")
default:
t.Fatalf("unexpected request path: %s", r.URL.Path)
}
}))
defer server.Close()
client, err := NewClient(ClientConfig{
Host: server.URL,
User: "apitest@pmg",
TokenName: "apitoken",
TokenValue: "secret",
VerifySSL: false,
})
if err != nil {
t.Fatalf("unexpected error creating token client: %v", err)
}
stats, err := client.GetMailStatistics(context.Background(), "")
if err != nil {
t.Fatalf("get mail statistics failed: %v", err)
}
if stats == nil || stats.Count != 42 {
t.Fatalf("expected statistics count 42, got %+v", stats)
}
expected := "PMGAPIToken=apitest@pmg!apitoken:secret"
if authHeader != expected {
t.Fatalf("expected authorization header %q, got %q", expected, authHeader)
}
}