Pulse/internal/api/reporting_handlers_test.go
rcourtman e6c0c4d385 fix(reporting): resolve unified resource IDs to metrics targets and render honest report subjects
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.
2026-06-10 17:10:30 +01:00

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)
}
}