Files
minstrel/internal/api/admin_scan_schedule_test.go
T
bvandeusen 837db5c929 test(server/m7-recurring-scans): TZ-tolerant next-fire assertion
The PATCH-daily test parsed the wire RFC3339 timestamp and asserted
.UTC().Hour() == 3, but the handler computes NextFire against
time.Now() in the host zone before serializing as UTC — so non-UTC
runners would see a non-3 UTC hour. Switch the assertion to
.Local().Hour() to match the handler's computation regardless of
the runner's TZ.
2026-05-06 22:59:23 -04:00

182 lines
5.7 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
func newAdminScheduleRouter(h *handlers) chi.Router {
r := chi.NewRouter()
r.Route("/api/admin", func(admin chi.Router) {
admin.Use(auth.RequireAdmin())
admin.Get("/scan/schedule", h.handleGetScanSchedule)
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
})
return r
}
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedget", "pw", true)
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp scanScheduleResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Mode != "off" {
t.Errorf("Mode = %q, want off", resp.Mode)
}
if resp.NextScheduledAt != nil {
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
}
}
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedpatch", "pw", true)
body := `{"mode":"daily","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp scanScheduleResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Mode != "daily" {
t.Errorf("Mode = %q, want daily", resp.Mode)
}
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
}
if resp.NextScheduledAt == nil {
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
} else {
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
if err != nil {
t.Errorf("NextScheduledAt parse: %v", err)
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
// Handler computes next-fire from time.Now() in the host zone, so
// the local-time hour is what's stable across CI runners regardless
// of TZ; the wire timestamp is UTC but parses back to the same
// instant either way.
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
}
}
}
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedweekly", "pw", true)
body := `{"mode":"weekly","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
admin := seedUser(t, pool, "schedoff", "pw", true)
if _, err := pool.Exec(context.Background(),
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
); err != nil {
t.Fatalf("seed: %v", err)
}
body := `{"mode":"off","time_of_day":"03:00"}`
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
req = withUser(req, admin)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var timeOfDay *string
if err := pool.QueryRow(context.Background(),
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
).Scan(&timeOfDay); err != nil {
t.Fatalf("verify: %v", err)
}
if timeOfDay != nil {
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
}
}
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
h, pool := testHandlers(t)
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
h.scheduler = s
user := seedUser(t, pool, "schednonadmin", "pw", false)
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
req = withUser(req, user)
rec := httptest.NewRecorder()
newAdminScheduleRouter(h).ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("status = %d, want 403", rec.Code)
}
}