mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-25 08:52:06 +00:00
Performance reports were structurally disconnected from the v6 ID space: the UI (and any API caller working from /api/state) addresses resources by canonical unified ID, while the metrics store is keyed by each platform's native source ID (the resource's metricsTarget). The engine queried the store with the unified ID verbatim, so every report rendered 'Data Points: 0' regardless of how much history existed, and covers showed raw hash IDs a report reader cannot map to a machine. - MetricReportRequest gains MetricsResourceID: handlers resolve the unified ID through the tenant monitor's resource store (new Monitor.MetricsTargetForResource accessor; the registry computes targets on demand, they are not persisted on snapshot structs) and the engine uses it for store queries only. Recovery points and Patrol findings stay keyed by the unified ID. - Legacy snapshot models and their alerts are keyed by the metrics target ID, so enrichment now matches either ID space and resource names/status resolve again on covers, headers, and fleet rows. - Fleet summaries mirror the single-report guard: zero data points across the fleet renders a muted NO DATA card instead of a green HEALTHY 'All systems operating normally' - false reassurance is the worst failure mode for a client-facing stability report. - Em dashes in PDF-bound literals become hyphens; fpdf core fonts are cp1252 and rendered them as mojibake.
973 lines
34 KiB
Go
973 lines
34 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
|
)
|
|
|
|
type stubReportingEngine struct {
|
|
data []byte
|
|
contentType string
|
|
err error
|
|
lastReq reporting.MetricReportRequest
|
|
lastMulti reporting.MultiReportRequest
|
|
}
|
|
|
|
func (s *stubReportingEngine) Generate(req reporting.MetricReportRequest) ([]byte, string, error) {
|
|
s.lastReq = req
|
|
if s.err != nil {
|
|
return nil, "", s.err
|
|
}
|
|
return s.data, s.contentType, nil
|
|
}
|
|
|
|
func (s *stubReportingEngine) GenerateMulti(req reporting.MultiReportRequest) ([]byte, string, error) {
|
|
s.lastMulti = req
|
|
if s.err != nil {
|
|
return nil, "", s.err
|
|
}
|
|
return s.data, s.contentType, nil
|
|
}
|
|
|
|
func (s *stubReportingEngine) NarrativeFor(req reporting.MetricReportRequest) (*reporting.Narrative, error) {
|
|
s.lastReq = req
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
return &reporting.Narrative{Source: reporting.NarrativeSourceHeuristic}, nil
|
|
}
|
|
|
|
func (s *stubReportingEngine) FleetNarrativeFor(req reporting.MultiReportRequest) (*reporting.FleetNarrative, error) {
|
|
s.lastMulti = req
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
return &reporting.FleetNarrative{Source: reporting.NarrativeSourceHeuristic}, nil
|
|
}
|
|
|
|
type reportBrandSettingsStore struct {
|
|
settings *config.SystemSettings
|
|
}
|
|
|
|
func (s reportBrandSettingsStore) LoadSystemSettings() (*config.SystemSettings, error) {
|
|
return s.settings, nil
|
|
}
|
|
|
|
type reportBrandLicenseProvider struct {
|
|
service *pkglicensing.Service
|
|
}
|
|
|
|
func (p reportBrandLicenseProvider) Service(context.Context) *pkglicensing.Service {
|
|
return p.service
|
|
}
|
|
|
|
func TestReportingHandlers_MethodNotAllowed(t *testing.T) {
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/reporting", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_EngineUnavailable(t *testing.T) {
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(nil)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/reporting?resourceType=node&resourceId=1", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusInternalServerError {
|
|
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rr.Code)
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp["code"] != "engine_unavailable" {
|
|
t.Fatalf("expected engine_unavailable, got %#v", resp["code"])
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_InvalidFormatAndParams(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("ok"), contentType: "text/plain"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=txt&resourceType=node&resourceId=1", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.HandleGenerateReport(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf", nil)
|
|
rr = httptest.NewRecorder()
|
|
handler.HandleGenerateReport(rr, req)
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
|
|
start := time.Now().Add(-2 * time.Hour).UTC().Format(time.RFC3339)
|
|
end := time.Now().UTC().Format(time.RFC3339)
|
|
query := url.Values{
|
|
"format": []string{"pdf"},
|
|
"resourceType": []string{"node"},
|
|
"resourceId": []string{"node-1"},
|
|
"metricType": []string{"cpu"},
|
|
"start": []string{start},
|
|
"end": []string{end},
|
|
"title": []string{"Node report"},
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/reporting?"+query.Encode(), nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); ct != "application/pdf" {
|
|
t.Fatalf("expected content-type application/pdf, got %q", ct)
|
|
}
|
|
definition := reporting.DescribePerformanceReport()
|
|
if disp := rr.Header().Get("Content-Disposition"); !strings.Contains(disp, definition.SingleFilenamePrefix+"-node-1") {
|
|
t.Fatalf("expected content-disposition to contain canonical filename prefix, got %q", disp)
|
|
}
|
|
if body := rr.Body.String(); body != "report" {
|
|
t.Fatalf("expected report body, got %q", body)
|
|
}
|
|
|
|
if engine.lastReq.ResourceType != "node" || engine.lastReq.ResourceID != "node-1" {
|
|
t.Fatalf("unexpected request: %+v", engine.lastReq)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_AttachesEntitledReportBranding(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
service := pkglicensing.NewService()
|
|
service.SetCurrentForTesting(&pkglicensing.License{
|
|
Claims: pkglicensing.Claims{
|
|
LicenseID: "lic_report_branding",
|
|
Email: "brand@example.test",
|
|
Tier: pkglicensing.TierEnterprise,
|
|
},
|
|
ValidatedAt: time.Now(),
|
|
})
|
|
SetLicenseServiceProvider(reportBrandLicenseProvider{service: service})
|
|
t.Cleanup(func() { SetLicenseServiceProvider(nil) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
handler.SetSystemSettingsStore(reportBrandSettingsStore{settings: &config.SystemSettings{
|
|
ReportBranding: &config.ReportBrandSettings{
|
|
DisplayName: "Client Alpha MSP",
|
|
LogoBase64: "iVBORw0KGgo=",
|
|
LogoFormat: "png",
|
|
},
|
|
}})
|
|
t.Setenv("PULSE_REPORT_PROVIDER_BRAND_DISPLAY_NAME", "Provider Default")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf&resourceType=node&resourceId=node-1", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
if !engine.lastReq.Branding.Entitled {
|
|
t.Fatal("expected report branding to be marked entitled")
|
|
}
|
|
if got := engine.lastReq.Branding.ProviderDefault.DisplayName; got != "Provider Default" {
|
|
t.Fatalf("provider brand = %q, want Provider Default", got)
|
|
}
|
|
if got := engine.lastReq.Branding.WorkspaceOverride.DisplayName; got != "Client Alpha MSP" {
|
|
t.Fatalf("workspace brand = %q, want Client Alpha MSP", got)
|
|
}
|
|
if len(engine.lastReq.Branding.WorkspaceOverride.LogoData) == 0 {
|
|
t.Fatal("expected workspace logo base64 to be decoded into logo data")
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_StripsWorkspaceReportBrandLogoPath(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
service := pkglicensing.NewService()
|
|
service.SetCurrentForTesting(&pkglicensing.License{
|
|
Claims: pkglicensing.Claims{
|
|
LicenseID: "lic_report_branding_path",
|
|
Email: "brand-path@example.test",
|
|
Tier: pkglicensing.TierEnterprise,
|
|
},
|
|
ValidatedAt: time.Now(),
|
|
})
|
|
SetLicenseServiceProvider(reportBrandLicenseProvider{service: service})
|
|
t.Cleanup(func() { SetLicenseServiceProvider(nil) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
handler.SetSystemSettingsStore(reportBrandSettingsStore{settings: &config.SystemSettings{
|
|
ReportBranding: &config.ReportBrandSettings{
|
|
DisplayName: "Client Path",
|
|
LogoPath: "/etc/pulse/secrets/handoff.key",
|
|
},
|
|
}})
|
|
t.Setenv("PULSE_REPORT_PROVIDER_BRAND_LOGO_PATH", "/etc/pulse/provider-brand.png")
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf&resourceType=node&resourceId=node-1", nil)
|
|
rr := httptest.NewRecorder()
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
if got := engine.lastReq.Branding.ProviderDefault.LogoPath; got != "/etc/pulse/provider-brand.png" {
|
|
t.Fatalf("provider logo path = %q, want provider env path", got)
|
|
}
|
|
if got := engine.lastReq.Branding.WorkspaceOverride.LogoPath; got != "" {
|
|
t.Fatalf("workspace logo path must be stripped, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport_TrimsOptionalFields(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/reporting?format=pdf&resourceType=node&resourceId=node-1&metricType=+cpu+&title=+Node+report+",
|
|
nil,
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
if engine.lastReq.MetricType != "cpu" {
|
|
t.Fatalf("expected trimmed metric type, got %q", engine.lastReq.MetricType)
|
|
}
|
|
if engine.lastReq.Title != "Node report" {
|
|
t.Fatalf("expected trimmed title, got %q", engine.lastReq.Title)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport_RejectsLegacyResourceTypeAlias(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf&resourceType=container&resourceId=ct-200", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
if engine.lastReq.ResourceType != "" {
|
|
t.Fatalf("expected engine not to be called for legacy alias, got %+v", engine.lastReq)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeReportResourceType_RejectsLegacyAliases(t *testing.T) {
|
|
tests := []string{"host", "container"}
|
|
for _, input := range tests {
|
|
t.Run(input, func(t *testing.T) {
|
|
got, err := normalizeReportResourceType(input)
|
|
if err == nil {
|
|
t.Fatalf("expected error for legacy alias %q, got canonical type %q", input, got)
|
|
}
|
|
if !strings.Contains(err.Error(), `unsupported resourceType "`+input+`"`) {
|
|
t.Fatalf("unexpected error for %q: %v", input, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport_RejectsUnsupportedResourceType(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/reporting?format=pdf&resourceType=host&resourceId=h-1", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
if engine.lastReq.ResourceType != "" {
|
|
t.Fatalf("expected engine not to be called for unsupported type, got %+v", engine.lastReq)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport_AcceptsCanonicalAppContainerType(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/reporting?format=pdf&resourceType=app-container&resourceId=docker-1",
|
|
nil,
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
if engine.lastReq.ResourceType != "app-container" {
|
|
t.Fatalf("expected canonical resource type app-container, got %q", engine.lastReq.ResourceType)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeFilename(t *testing.T) {
|
|
raw := "\"bad/../name\\\r\n"
|
|
got := sanitizeFilename(raw)
|
|
if strings.ContainsAny(got, "\"\\/\r\n") {
|
|
t.Fatalf("sanitizeFilename did not remove unsafe characters: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_ExportVMInventory_MethodNotAllowed(t *testing.T) {
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/inventory/vms/export", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleExportVMInventory(rr, req)
|
|
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GetReportingCatalog_MethodNotAllowed(t *testing.T) {
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/catalog", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGetReportingCatalog(rr, req)
|
|
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GetReportingCatalog_ReturnsCanonicalDefinition(t *testing.T) {
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGetReportingCatalog(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
|
|
}
|
|
if got := rr.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
|
|
t.Fatalf("expected JSON content-type, got %q", got)
|
|
}
|
|
|
|
var payload reporting.ReportingCatalog
|
|
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode catalog response: %v", err)
|
|
}
|
|
if payload.ID != "advanced_reporting" {
|
|
t.Fatalf("expected advanced_reporting id, got %q", payload.ID)
|
|
}
|
|
if payload.PerformanceReport.ID != "performance_reports" {
|
|
t.Fatalf("expected performance report definition, got %#v", payload.PerformanceReport)
|
|
}
|
|
if payload.VMInventoryExport.ID != "vm_inventory" {
|
|
t.Fatalf("expected vm inventory definition, got %#v", payload.VMInventoryExport)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_ExportVMInventory_InvalidFormat(t *testing.T) {
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export?format=pdf", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleExportVMInventory(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
var payload struct {
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode invalid-format response: %v", err)
|
|
}
|
|
if payload.Error != reporting.DescribeVMInventoryExport().InvalidFormatError() {
|
|
t.Fatalf("expected canonical inventory invalid-format error, got %q", payload.Error)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateMultiReport_UsesCatalogLimit(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "text/csv"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
definition := reporting.DescribePerformanceReport()
|
|
resources := make([]string, 0, definition.MultiResourceMax+1)
|
|
for i := 0; i < definition.MultiResourceMax+1; i++ {
|
|
resources = append(resources, fmt.Sprintf(`{"resourceType":"vm","resourceId":"vm-%d"}`, i))
|
|
}
|
|
body := `{"resources":[` + strings.Join(resources, ",") + `],"format":"csv"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/reporting/generate-multi", strings.NewReader(body))
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateMultiReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), fmt.Sprintf("Maximum %d resources allowed", definition.MultiResourceMax)) {
|
|
t.Fatalf("expected canonical multi-resource max in error, got %s", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateMultiReport_TrimsOptionalFieldsAndUsesCanonicalFilename(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "text/csv"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
body := `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"csv","metricType":" cpu ","title":" Fleet report "}`
|
|
req := httptest.NewRequest(http.MethodPost, "/api/reporting/generate-multi", strings.NewReader(body))
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateMultiReport(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
definition := reporting.DescribePerformanceReport()
|
|
if disp := rr.Header().Get("Content-Disposition"); !strings.Contains(disp, definition.MultiFilenamePrefix+"-") {
|
|
t.Fatalf("expected canonical multi-report filename prefix, got %q", disp)
|
|
}
|
|
if engine.lastMulti.MetricType != "cpu" {
|
|
t.Fatalf("expected trimmed metric type, got %q", engine.lastMulti.MetricType)
|
|
}
|
|
if engine.lastMulti.Title != "Fleet report" {
|
|
t.Fatalf("expected trimmed title, got %q", engine.lastMulti.Title)
|
|
}
|
|
if len(engine.lastMulti.Resources) != 1 {
|
|
t.Fatalf("expected one resource request, got %+v", engine.lastMulti.Resources)
|
|
}
|
|
if engine.lastMulti.Resources[0].MetricType != "cpu" || engine.lastMulti.Resources[0].Title != "Fleet report" {
|
|
t.Fatalf("expected canonical optional fields to propagate to per-resource requests, got %+v", engine.lastMulti.Resources[0])
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport_UsesCatalogDefaultRangeDuration(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
end := time.Date(2026, 3, 25, 15, 0, 0, 0, time.UTC)
|
|
req := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/api/reporting?format=pdf&resourceType=node&resourceId=node-1&end="+url.QueryEscape(end.Format(time.RFC3339)),
|
|
nil,
|
|
)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
if got := engine.lastReq.Start; !got.Equal(end.Add(-reporting.DescribePerformanceReport().DefaultRangeDuration())) {
|
|
t.Fatalf("expected canonical default start, got %s", got)
|
|
}
|
|
if !engine.lastReq.End.Equal(end) {
|
|
t.Fatalf("expected canonical end time, got %s", engine.lastReq.End)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport_RejectsInvalidTimeRange(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "invalid start",
|
|
url: "/api/reporting?format=pdf&resourceType=node&resourceId=node-1&start=not-a-time",
|
|
wantMessage: "start must be RFC3339",
|
|
},
|
|
{
|
|
name: "invalid end",
|
|
url: "/api/reporting?format=pdf&resourceType=node&resourceId=node-1&end=not-a-time",
|
|
wantMessage: "end must be RFC3339",
|
|
},
|
|
{
|
|
name: "end before start",
|
|
url: "/api/reporting?format=pdf&resourceType=node&resourceId=node-1&start=2026-03-25T12:00:00Z&end=2026-03-25T11:00:00Z",
|
|
wantMessage: "end must be after start",
|
|
},
|
|
{
|
|
name: "range too large",
|
|
url: "/api/reporting?format=pdf&resourceType=node&resourceId=node-1&start=2025-03-24T12:00:00Z&end=2026-03-25T12:00:01Z",
|
|
wantMessage: "report window must be 366 days or less",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), tc.wantMessage) {
|
|
t.Fatalf("expected error %q, got %s", tc.wantMessage, rr.Body.String())
|
|
}
|
|
if engine.lastReq.ResourceID != "" {
|
|
t.Fatalf("expected engine not to be called, got %+v", engine.lastReq)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateReport_RejectsInvalidOptionalFields(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
longTitle := strings.Repeat("x", reportingMaxTitleLength+1)
|
|
tests := []struct {
|
|
name string
|
|
url string
|
|
wantCode string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "invalid metric type characters",
|
|
url: "/api/reporting?format=pdf&resourceType=node&resourceId=node-1&metricType=" + url.QueryEscape("cpu usage"),
|
|
wantCode: "invalid_metric_type",
|
|
wantMessage: "metricType must match [a-zA-Z0-9._:-]+ and be <= 64 chars",
|
|
},
|
|
{
|
|
name: "title too long",
|
|
url: "/api/reporting?format=pdf&resourceType=node&resourceId=node-1&title=" + url.QueryEscape(longTitle),
|
|
wantCode: "invalid_title",
|
|
wantMessage: "title must be <= 256 chars",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
var payload struct {
|
|
Code string `json:"code"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode error payload: %v", err)
|
|
}
|
|
if payload.Code != tc.wantCode || payload.Error != tc.wantMessage {
|
|
t.Fatalf("unexpected payload: %+v", payload)
|
|
}
|
|
if engine.lastReq.ResourceID != "" {
|
|
t.Fatalf("expected engine not to be called, got %+v", engine.lastReq)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateMultiReport_RejectsInvalidTimeRange(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "invalid start",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","start":"not-a-time"}`,
|
|
wantMessage: "start must be RFC3339",
|
|
},
|
|
{
|
|
name: "invalid end",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","end":"not-a-time"}`,
|
|
wantMessage: "end must be RFC3339",
|
|
},
|
|
{
|
|
name: "end before start",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","start":"2026-03-25T12:00:00Z","end":"2026-03-25T11:00:00Z"}`,
|
|
wantMessage: "end must be after start",
|
|
},
|
|
{
|
|
name: "range too large",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","start":"2025-03-24T12:00:00Z","end":"2026-03-25T12:00:01Z"}`,
|
|
wantMessage: "report window must be 366 days or less",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/reporting/generate-multi", strings.NewReader(tc.body))
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateMultiReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
if !strings.Contains(rr.Body.String(), tc.wantMessage) {
|
|
t.Fatalf("expected error %q, got %s", tc.wantMessage, rr.Body.String())
|
|
}
|
|
if len(engine.lastMulti.Resources) != 0 {
|
|
t.Fatalf("expected engine not to be called, got %+v", engine.lastMulti)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateMultiReport_RejectsInvalidOptionalFields(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
longTitle := strings.Repeat("x", reportingMaxTitleLength+1)
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantCode string
|
|
wantMessage string
|
|
}{
|
|
{
|
|
name: "invalid metric type characters",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","metricType":"cpu usage"}`,
|
|
wantCode: "invalid_metric_type",
|
|
wantMessage: "metricType must match [a-zA-Z0-9._:-]+ and be <= 64 chars",
|
|
},
|
|
{
|
|
name: "title too long",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","title":"` + longTitle + `"}`,
|
|
wantCode: "invalid_title",
|
|
wantMessage: "title must be <= 256 chars",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/reporting/generate-multi", strings.NewReader(tc.body))
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateMultiReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
var payload struct {
|
|
Code string `json:"code"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode error payload: %v", err)
|
|
}
|
|
if payload.Code != tc.wantCode || payload.Error != tc.wantMessage {
|
|
t.Fatalf("unexpected payload: %+v", payload)
|
|
}
|
|
if len(engine.lastMulti.Resources) != 0 {
|
|
t.Fatalf("expected engine not to be called, got %+v", engine.lastMulti)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateMultiReport_RejectsInvalidBody(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantCode string
|
|
}{
|
|
{
|
|
name: "unknown field",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","extra":true}`,
|
|
wantCode: "invalid_body",
|
|
},
|
|
{
|
|
name: "trailing payload",
|
|
body: `{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf"}{"extra":true}`,
|
|
wantCode: "invalid_body",
|
|
},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/reporting/generate-multi", strings.NewReader(tc.body))
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateMultiReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
var payload struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode error payload: %v", err)
|
|
}
|
|
if payload.Code != tc.wantCode {
|
|
t.Fatalf("expected code %q, got %+v", tc.wantCode, payload)
|
|
}
|
|
if len(engine.lastMulti.Resources) != 0 {
|
|
t.Fatalf("expected engine not to be called, got %+v", engine.lastMulti)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_GenerateMultiReport_RejectsOversizedBody(t *testing.T) {
|
|
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(engine)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handler := NewReportingHandlers(nil, nil)
|
|
padding := strings.Repeat("x", reportingMultiReportBodyMax)
|
|
body := fmt.Sprintf(`{"resources":[{"resourceType":"vm","resourceId":"vm-1"}],"format":"pdf","title":"%s"}`, padding)
|
|
req := httptest.NewRequest(http.MethodPost, "/api/reporting/generate-multi", strings.NewReader(body))
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleGenerateMultiReport(rr, req)
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusBadRequest, rr.Code, rr.Body.String())
|
|
}
|
|
var payload struct {
|
|
Code string `json:"code"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(rr.Body).Decode(&payload); err != nil {
|
|
t.Fatalf("decode error payload: %v", err)
|
|
}
|
|
if payload.Code != "body_too_large" || payload.Error != "Request body must be 1MB or less" {
|
|
t.Fatalf("unexpected payload: %+v", payload)
|
|
}
|
|
if len(engine.lastMulti.Resources) != 0 {
|
|
t.Fatalf("expected engine not to be called, got %+v", engine.lastMulti)
|
|
}
|
|
}
|
|
|
|
func TestReportingHandlers_ExportVMInventory_EmptySnapshotStillReturnsCSVHeader(t *testing.T) {
|
|
handler := NewReportingHandlers(nil, nil)
|
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export", nil)
|
|
rr := httptest.NewRecorder()
|
|
|
|
handler.HandleExportVMInventory(rr, req)
|
|
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("expected status %d, got %d body=%s", http.StatusOK, rr.Code, rr.Body.String())
|
|
}
|
|
if contentType := rr.Header().Get("Content-Type"); !strings.Contains(contentType, "text/csv") {
|
|
t.Fatalf("expected CSV content type, got %q", contentType)
|
|
}
|
|
if !strings.Contains(rr.Body.String(), "Resource ID,Instance,Node,Pool,VMID,VM Name") {
|
|
t.Fatalf("expected CSV header row, got %q", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBuildVMInventoryRows_UsesCanonicalFieldsAndDiskFallback(t *testing.T) {
|
|
total := int64(16 * 1024)
|
|
resources := []unifiedresources.Resource{
|
|
{
|
|
ID: "vm-101",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
Name: "app-vm",
|
|
Status: unifiedresources.StatusWarning,
|
|
Metrics: &unifiedresources.ResourceMetrics{
|
|
Memory: &unifiedresources.MetricValue{Total: &total},
|
|
},
|
|
Proxmox: &unifiedresources.ProxmoxData{
|
|
Instance: "lab",
|
|
NodeName: "pve-a",
|
|
Pool: "prod",
|
|
VMID: 101,
|
|
CPUs: 4,
|
|
DiskStatusReason: "guest agent offline",
|
|
Disks: []unifiedresources.DiskInfo{
|
|
{Device: "scsi0", Total: 100 * 1024, Used: 40 * 1024},
|
|
{Device: "scsi1", Total: 50 * 1024, Used: 10 * 1024},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
ID: "node-1",
|
|
Type: unifiedresources.ResourceTypeAgent,
|
|
Name: "node-a",
|
|
},
|
|
}
|
|
|
|
rows := buildVMInventoryRows(resources)
|
|
if len(rows) != 1 {
|
|
t.Fatalf("expected one VM row, got %d", len(rows))
|
|
}
|
|
row := rows[0]
|
|
if row.ResourceID != "vm-101" || row.Name != "app-vm" || row.Instance != "lab" || row.Node != "pve-a" {
|
|
t.Fatalf("unexpected inventory row identity: %+v", row)
|
|
}
|
|
if row.Pool != "prod" {
|
|
t.Fatalf("expected pool to come from canonical model, got %+v", row)
|
|
}
|
|
if row.CPUCores != 4 || row.MemoryAllocatedBytes != total {
|
|
t.Fatalf("expected CPU and memory totals from canonical model, got %+v", row)
|
|
}
|
|
if row.DiskAllocatedBytes != 150*1024 || row.DiskUsedBytes != 50*1024 {
|
|
t.Fatalf("expected disk totals to fall back to per-disk sums, got %+v", row)
|
|
}
|
|
if row.DiskStatusReason != "guest agent offline" {
|
|
t.Fatalf("expected disk status reason to be preserved, got %+v", row)
|
|
}
|
|
}
|
|
|
|
// TestResolveReportSubject_TranslatesUnifiedIDToMetricsTarget pins the
|
|
// unified-ID → metrics-target translation. The v6 UI addresses report
|
|
// subjects by canonical unified ID while the metrics store is keyed by the
|
|
// platform-native source ID carried in the resource's metricsTarget;
|
|
// without the translation every report rendered "Data Points: 0".
|
|
func TestResolveReportSubject_TranslatesUnifiedIDToMetricsTarget(t *testing.T) {
|
|
handlers := NewReportingHandlers(nil, nil)
|
|
snapshot := reportingEnrichmentSnapshot{
|
|
Resources: []unifiedresources.Resource{
|
|
{
|
|
ID: "vm-7f8b2b6cd98c2089",
|
|
Name: "checkout-web-01",
|
|
Type: unifiedresources.ResourceTypeVM,
|
|
MetricsTarget: &unifiedresources.MetricsTarget{
|
|
ResourceType: "vm",
|
|
ResourceID: "mock-cluster-pve1-100",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
req := reporting.MetricReportRequest{
|
|
ResourceType: "vm",
|
|
ResourceID: "vm-7f8b2b6cd98c2089",
|
|
}
|
|
handlers.resolveReportSubject("default", &req, snapshot)
|
|
|
|
if req.MetricsResourceID != "mock-cluster-pve1-100" {
|
|
t.Fatalf("MetricsResourceID = %q, want mock-cluster-pve1-100", req.MetricsResourceID)
|
|
}
|
|
if req.ResourceID != "vm-7f8b2b6cd98c2089" {
|
|
t.Fatalf("ResourceID must stay canonical, got %q", req.ResourceID)
|
|
}
|
|
if req.Resource == nil || req.Resource.Name != "checkout-web-01" {
|
|
t.Fatalf("expected display name seeded from unified resource, got %+v", req.Resource)
|
|
}
|
|
|
|
// Legacy snapshot models and alerts are keyed by the metrics-target ID;
|
|
// both ID spaces must match the subject.
|
|
if !reportSubjectIDMatches(&req, "vm-7f8b2b6cd98c2089") {
|
|
t.Fatal("expected canonical unified ID to match subject")
|
|
}
|
|
if !reportSubjectIDMatches(&req, "mock-cluster-pve1-100") {
|
|
t.Fatal("expected metrics-target ID to match subject")
|
|
}
|
|
if reportSubjectIDMatches(&req, "mock-cluster-pve1-101") {
|
|
t.Fatal("unrelated ID must not match subject")
|
|
}
|
|
|
|
// Unknown resources pass through untouched.
|
|
unknown := reporting.MetricReportRequest{ResourceType: "vm", ResourceID: "vm-missing"}
|
|
handlers.resolveReportSubject("default", &unknown, snapshot)
|
|
if unknown.MetricsResourceID != "" || unknown.Resource != nil {
|
|
t.Fatalf("expected no translation for unknown resource, got %+v", unknown)
|
|
}
|
|
}
|