mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 02:42:11 +00:00
257 lines
9.5 KiB
Go
257 lines
9.5 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/reporting"
|
|
)
|
|
|
|
func newTestReportingScheduleHandlers(t *testing.T) (*ReportingHandlers, *config.ConfigPersistence) {
|
|
t.Helper()
|
|
baseDir := t.TempDir()
|
|
mtp := config.NewMultiTenantPersistence(baseDir)
|
|
mtm := monitoring.NewMultiTenantMonitor(&config.Config{DataPath: baseDir}, mtp, nil)
|
|
t.Cleanup(mtm.Stop)
|
|
monitor, err := mtm.GetMonitor("default")
|
|
if err != nil {
|
|
t.Fatalf("GetMonitor: %v", err)
|
|
}
|
|
persistence := monitor.GetConfigPersistence()
|
|
if persistence == nil {
|
|
t.Fatal("monitor config persistence is nil")
|
|
}
|
|
return NewReportingHandlers(mtm, nil), persistence
|
|
}
|
|
|
|
func TestRunReportScheduleRequiresCurrentAdvancedReportingEntitlement(t *testing.T) {
|
|
handler := NewReportingHandlers(nil, nil)
|
|
service := newLicenseService()
|
|
handler.SetCommercialLicenseResolver(func(context.Context) *licenseService { return service })
|
|
now := time.Date(2026, 7, 14, 10, 0, 0, 0, time.UTC)
|
|
schedule := config.NormalizeReportSchedule(config.ReportSchedule{
|
|
ID: "report-schedule-entitlement",
|
|
Name: "Entitlement proof",
|
|
Enabled: true,
|
|
})
|
|
result, updated := handler.runReportSchedule(context.Background(), nil, schedule, now, false, "weekly:proof")
|
|
if result.path != "" || result.email != "" {
|
|
t.Fatalf("unentitled scheduler generated output: %+v", result)
|
|
}
|
|
if updated.LastRunStatus != config.ReportScheduleLastRunFailed || !strings.Contains(updated.LastError, "advanced reporting entitlement required") {
|
|
t.Fatalf("unexpected unentitled result: %+v", updated)
|
|
}
|
|
}
|
|
|
|
func TestPurgeGeneratedReportsAfterDowngradePreservesDefinitionsAndRejectsSymlinks(t *testing.T) {
|
|
baseDir := t.TempDir()
|
|
persistence := config.NewConfigPersistence(baseDir)
|
|
schedule := config.NormalizeReportSchedule(config.ReportSchedule{ID: "report-schedule-purge", Name: "Purge proof"})
|
|
report := generatedMultiReport{Filename: "report.pdf", Format: reporting.FormatPDF, Data: []byte("report")}
|
|
path, err := saveGeneratedReport(persistence, schedule, report)
|
|
if err != nil {
|
|
t.Fatalf("save report: %v", err)
|
|
}
|
|
outside := filepath.Join(t.TempDir(), "outside.pdf")
|
|
if err := os.WriteFile(outside, []byte("keep"), 0o600); err != nil {
|
|
t.Fatalf("write outside: %v", err)
|
|
}
|
|
if err := os.Symlink(outside, filepath.Join(filepath.Dir(path), "outside-link.pdf")); err != nil {
|
|
t.Fatalf("symlink: %v", err)
|
|
}
|
|
if err := purgeGeneratedReportsAfterDowngrade(persistence); err != nil {
|
|
t.Fatalf("purge: %v", err)
|
|
}
|
|
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("generated report still exists: %v", err)
|
|
}
|
|
if data, err := os.ReadFile(outside); err != nil || string(data) != "keep" {
|
|
t.Fatalf("outside target changed: data=%q err=%v", data, err)
|
|
}
|
|
}
|
|
|
|
func validReportSchedulePayload() config.ReportSchedule {
|
|
return config.ReportSchedule{
|
|
Name: "Monthly client report",
|
|
Enabled: true,
|
|
Cadence: config.ReportScheduleCadence{
|
|
Type: config.ReportScheduleCadenceMonthly,
|
|
DayOfMonth: 1,
|
|
Time: "09:00",
|
|
Timezone: "UTC",
|
|
},
|
|
Scope: config.ReportScheduleScope{
|
|
Resources: []config.ReportScheduleResource{{ResourceType: "vm", ResourceID: "vm-1", Name: "VM 1"}},
|
|
},
|
|
Format: config.ReportScheduleFormatPDF,
|
|
Delivery: config.ReportScheduleDelivery{
|
|
Method: config.ReportScheduleDeliveryDisk,
|
|
Attach: false,
|
|
SaveToDisk: true,
|
|
},
|
|
RetentionCount: 12,
|
|
}
|
|
}
|
|
|
|
func encodeScheduleBody(t *testing.T, schedule config.ReportSchedule) *bytes.Reader {
|
|
t.Helper()
|
|
data, err := json.Marshal(schedule)
|
|
if err != nil {
|
|
t.Fatalf("marshal schedule: %v", err)
|
|
}
|
|
return bytes.NewReader(data)
|
|
}
|
|
|
|
func TestReportScheduleHandlersPersistCRUD(t *testing.T) {
|
|
handlers, persistence := newTestReportingScheduleHandlers(t)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
|
|
createReq := httptest.NewRequest(http.MethodPost, "/api/admin/reports/schedules", encodeScheduleBody(t, validReportSchedulePayload())).WithContext(ctx)
|
|
createRec := httptest.NewRecorder()
|
|
handlers.HandleCreateReportSchedule(createRec, createReq)
|
|
if createRec.Code != http.StatusCreated {
|
|
t.Fatalf("create status = %d, want %d body=%q", createRec.Code, http.StatusCreated, createRec.Body.String())
|
|
}
|
|
var created config.ReportSchedule
|
|
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
|
t.Fatalf("decode create: %v", err)
|
|
}
|
|
if created.ID == "" {
|
|
t.Fatal("created schedule ID is empty")
|
|
}
|
|
|
|
stored, err := persistence.LoadReportScheduleStore()
|
|
if err != nil {
|
|
t.Fatalf("LoadReportScheduleStore: %v", err)
|
|
}
|
|
if len(stored.Schedules) != 1 || stored.Schedules[0].ID != created.ID {
|
|
t.Fatalf("stored schedules = %+v, want created schedule", stored.Schedules)
|
|
}
|
|
|
|
listReq := httptest.NewRequest(http.MethodGet, "/api/admin/reports/schedules", nil).WithContext(ctx)
|
|
listRec := httptest.NewRecorder()
|
|
handlers.HandleListReportSchedules(listRec, listReq)
|
|
if listRec.Code != http.StatusOK {
|
|
t.Fatalf("list status = %d, want %d body=%q", listRec.Code, http.StatusOK, listRec.Body.String())
|
|
}
|
|
var list reportScheduleListResponse
|
|
if err := json.NewDecoder(listRec.Body).Decode(&list); err != nil {
|
|
t.Fatalf("decode list: %v", err)
|
|
}
|
|
if len(list.Schedules) != 1 || list.Schedules[0].ID != created.ID {
|
|
t.Fatalf("list schedules = %+v, want created schedule", list.Schedules)
|
|
}
|
|
|
|
created.Enabled = false
|
|
created.Name = "Updated monthly report"
|
|
updateReq := httptest.NewRequest(http.MethodPut, "/api/admin/reports/schedules/"+created.ID, encodeScheduleBody(t, created)).WithContext(ctx)
|
|
updateReq.SetPathValue("id", created.ID)
|
|
updateRec := httptest.NewRecorder()
|
|
handlers.HandleUpdateReportSchedule(updateRec, updateReq)
|
|
if updateRec.Code != http.StatusOK {
|
|
t.Fatalf("update status = %d, want %d body=%q", updateRec.Code, http.StatusOK, updateRec.Body.String())
|
|
}
|
|
var updated config.ReportSchedule
|
|
if err := json.NewDecoder(updateRec.Body).Decode(&updated); err != nil {
|
|
t.Fatalf("decode update: %v", err)
|
|
}
|
|
if updated.Enabled || updated.Name != "Updated monthly report" {
|
|
t.Fatalf("updated schedule = %+v, want disabled updated name", updated)
|
|
}
|
|
|
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/api/admin/reports/schedules/"+created.ID, nil).WithContext(ctx)
|
|
deleteReq.SetPathValue("id", created.ID)
|
|
deleteRec := httptest.NewRecorder()
|
|
handlers.HandleDeleteReportSchedule(deleteRec, deleteReq)
|
|
if deleteRec.Code != http.StatusNoContent {
|
|
t.Fatalf("delete status = %d, want %d body=%q", deleteRec.Code, http.StatusNoContent, deleteRec.Body.String())
|
|
}
|
|
stored, err = persistence.LoadReportScheduleStore()
|
|
if err != nil {
|
|
t.Fatalf("LoadReportScheduleStore after delete: %v", err)
|
|
}
|
|
if len(stored.Schedules) != 0 {
|
|
t.Fatalf("stored schedules after delete = %+v, want empty", stored.Schedules)
|
|
}
|
|
}
|
|
|
|
func TestRunReportSchedulePersistsFailureStatusWhenEngineUnavailable(t *testing.T) {
|
|
original := reporting.GetEngine()
|
|
reporting.SetEngine(nil)
|
|
t.Cleanup(func() { reporting.SetEngine(original) })
|
|
|
|
handlers, persistence := newTestReportingScheduleHandlers(t)
|
|
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
|
|
schedule := validReportSchedulePayload()
|
|
schedule.ID = "schedule-run-failure"
|
|
if err := persistence.SaveReportScheduleStore(config.ReportScheduleStore{Schedules: []config.ReportSchedule{schedule}}); err != nil {
|
|
t.Fatalf("SaveReportScheduleStore: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/reports/schedules/schedule-run-failure/run", nil).WithContext(ctx)
|
|
req.SetPathValue("id", "schedule-run-failure")
|
|
rec := httptest.NewRecorder()
|
|
handlers.HandleRunReportSchedule(rec, req)
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("run status = %d, want %d body=%q", rec.Code, http.StatusInternalServerError, rec.Body.String())
|
|
}
|
|
|
|
stored, err := persistence.LoadReportScheduleStore()
|
|
if err != nil {
|
|
t.Fatalf("LoadReportScheduleStore: %v", err)
|
|
}
|
|
if len(stored.Schedules) != 1 {
|
|
t.Fatalf("stored schedule len = %d, want 1", len(stored.Schedules))
|
|
}
|
|
if stored.Schedules[0].LastRunStatus != config.ReportScheduleLastRunFailed {
|
|
t.Fatalf("last_run_status = %q, want failed", stored.Schedules[0].LastRunStatus)
|
|
}
|
|
if stored.Schedules[0].LastRunAt == nil || stored.Schedules[0].LastError == "" {
|
|
t.Fatalf("run status fields = lastRunAt %v lastError %q, want populated", stored.Schedules[0].LastRunAt, stored.Schedules[0].LastError)
|
|
}
|
|
}
|
|
|
|
func TestSaveGeneratedReportUsesHashedScheduleDirectory(t *testing.T) {
|
|
_, persistence := newTestReportingScheduleHandlers(t)
|
|
schedule := validReportSchedulePayload()
|
|
schedule.ID = "../tenant-owned-schedule"
|
|
report := generatedMultiReport{
|
|
Data: []byte("report"),
|
|
Filename: "../unsafe-report.pdf",
|
|
Format: reporting.FormatPDF,
|
|
}
|
|
|
|
path, err := saveGeneratedReport(persistence, schedule, report)
|
|
if err != nil {
|
|
t.Fatalf("saveGeneratedReport() error = %v", err)
|
|
}
|
|
|
|
wantDir := filepath.Join(
|
|
persistence.DataDir(),
|
|
"reports",
|
|
"generated",
|
|
securityutil.HashedStorageName(schedule.ID),
|
|
)
|
|
if filepath.Dir(path) != wantDir {
|
|
t.Fatalf("report dir = %q, want %q", filepath.Dir(path), wantDir)
|
|
}
|
|
if strings.Contains(path, schedule.ID) {
|
|
t.Fatalf("report path leaked raw schedule ID: %q", path)
|
|
}
|
|
if strings.ContainsAny(filepath.Base(path), `/\`) {
|
|
t.Fatalf("report filename still contains a path separator: %q", filepath.Base(path))
|
|
}
|
|
}
|