Files
pulse/internal/api/report_schedules_test.go
T
rcourtman eecf45fafd Email the Patrol weekly summary as a report schedule kind
The customers most likely to lapse are the ones who stopped opening Pulse,
so the in-app "This week" card cannot reach them. Report schedules gain a
kind: the default resources kind is the existing PDF or CSV performance
report, and patrol_digest emails the same weekly "what Patrol did for you"
rollup the Patrol page shows, in plain language, for the whole workspace.
It reuses the existing scheduler, cadence, recipients, tenant email config,
and Pro advanced-reporting entitlement rather than adding a second
scheduler. Digest schedules are weekly and email-only; the server fixes
scope, format, and attachments, and a run with no AI service, no Patrol, or
no email destination records a last_error that names the missing piece
instead of sending an empty week. Nothing is written to disk.

The digest assembly moves from the HTTP handler into
AISettingsHandler.BuildPatrolDigest so the schedule runner and the
endpoint share one code path. docs/MSP.md documents the kind for
providers; the Settings > Reporting "Report type" selector follows in a
separate change once it has a Pro-licensed browser pass.
2026-09-02 11:15:22 +01:00

358 lines
15 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/ai"
"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))
}
}
func TestPreparePatrolDigestScheduleFixesFormatScopeAndDelivery(t *testing.T) {
handlers, _ := newTestReportingScheduleHandlers(t)
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
now := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
base := config.ReportSchedule{
ID: "digest-1",
Name: "Patrol weekly summary",
Enabled: true,
Kind: config.ReportScheduleKindPatrolDigest,
Cadence: config.ReportScheduleCadence{Type: config.ReportScheduleCadenceWeekly, Weekday: "monday", Time: "08:00", Timezone: "UTC"},
// Clients may still send resource-report fields; the digest kind ignores them.
Scope: config.ReportScheduleScope{Tags: []string{"production"}},
Format: config.ReportScheduleFormatPDF,
Delivery: config.ReportScheduleDelivery{Method: config.ReportScheduleDeliveryEmail, To: []string{"ops@example.com"}, Attach: true, SaveToDisk: true},
}
prepared, err := handlers.prepareReportSchedule(ctx, base, now, false)
if err != nil {
t.Fatalf("prepare digest schedule: %v", err)
}
if prepared.Format != config.ReportScheduleFormatEmail || prepared.Delivery.Attach || prepared.Delivery.SaveToDisk {
t.Fatalf("digest schedule must be an email-only summary, got %+v", prepared)
}
if len(prepared.Scope.Resources) != 0 || len(prepared.Scope.Tags) != 0 {
t.Fatalf("digest schedule must not carry a resource scope, got %+v", prepared.Scope)
}
if prepared.NextRunAt == nil || !prepared.NextRunAt.After(now) {
t.Fatalf("digest schedule needs a next run, got %v", prepared.NextRunAt)
}
monthly := base
monthly.Cadence = config.ReportScheduleCadence{Type: config.ReportScheduleCadenceMonthly, DayOfMonth: 1, Time: "08:00", Timezone: "UTC"}
if _, err := handlers.prepareReportSchedule(ctx, monthly, now, false); err == nil || !strings.Contains(err.Error(), "weekly") {
t.Fatalf("monthly digest must be rejected, got %v", err)
}
disk := base
disk.Delivery.Method = config.ReportScheduleDeliveryDisk
if _, err := handlers.prepareReportSchedule(ctx, disk, now, false); err == nil || !strings.Contains(err.Error(), "email") {
t.Fatalf("disk digest must be rejected, got %v", err)
}
unknown := base
unknown.Kind = "spreadsheet"
if _, err := handlers.prepareReportSchedule(ctx, unknown, now, false); err == nil || !strings.Contains(err.Error(), "kind") {
t.Fatalf("unknown kind must be rejected, got %v", err)
}
// Legacy schedules without a kind stay resource reports.
legacy := validReportSchedulePayload()
legacy.ID = "legacy-1"
preparedLegacy, err := handlers.prepareReportSchedule(ctx, legacy, now, false)
if err != nil {
t.Fatalf("prepare legacy schedule: %v", err)
}
if preparedLegacy.Kind != config.ReportScheduleKindResources || preparedLegacy.Format != config.ReportScheduleFormatPDF {
t.Fatalf("legacy schedule = %+v, want resources kind", preparedLegacy)
}
}
func TestRunPatrolDigestScheduleFailsClearlyWithoutDigestOrEmail(t *testing.T) {
handlers, persistence := newTestReportingScheduleHandlers(t)
ctx := context.WithValue(context.Background(), OrgIDContextKey, "default")
now := time.Date(2026, 9, 2, 10, 0, 0, 0, time.UTC)
schedule := config.NormalizeReportSchedule(config.ReportSchedule{
ID: "digest-run", Name: "Patrol weekly summary", Enabled: true, Kind: config.ReportScheduleKindPatrolDigest,
Cadence: config.ReportScheduleCadence{Type: config.ReportScheduleCadenceWeekly, Weekday: "monday", Time: "08:00", Timezone: "UTC"},
Format: config.ReportScheduleFormatEmail,
Delivery: config.ReportScheduleDelivery{Method: config.ReportScheduleDeliveryEmail},
})
_, withoutResolver := handlers.runReportSchedule(ctx, persistence, schedule, now, true, "")
if withoutResolver.LastRunStatus != config.ReportScheduleLastRunFailed || !strings.Contains(withoutResolver.LastError, "unavailable") {
t.Fatalf("run without resolver = %+v, want a clear unavailable failure", withoutResolver)
}
handlers.SetPatrolDigestResolver(func(context.Context, int) (ai.PatrolDigest, bool) { return ai.PatrolDigest{}, false })
_, notAvailable := handlers.runReportSchedule(ctx, persistence, schedule, now, true, "")
if notAvailable.LastRunStatus != config.ReportScheduleLastRunFailed || !strings.Contains(notAvailable.LastError, "not available") {
t.Fatalf("run without Patrol = %+v", notAvailable)
}
calls := 0
handlers.SetPatrolDigestResolver(func(_ context.Context, days int) (ai.PatrolDigest, bool) {
calls++
if days != ai.PatrolDigestDefaultDays {
t.Fatalf("digest days = %d, want %d", days, ai.PatrolDigestDefaultDays)
}
return ai.BuildPatrolDigest(ai.PatrolDigestInput{Now: now, Days: days}), true
})
_, noEmail := handlers.runReportSchedule(ctx, persistence, schedule, now, true, "")
if calls != 1 {
t.Fatalf("resolver calls = %d, want 1", calls)
}
if noEmail.LastRunStatus != config.ReportScheduleLastRunFailed || !strings.Contains(noEmail.LastError, "email notifications are not configured") {
t.Fatalf("run without email config = %+v, want a failure that names the missing email destination", noEmail)
}
if noEmail.NextRunAt == nil {
t.Fatal("failed digest run must still schedule the next occurrence")
}
}