3a089e7c1d
Warn via log when creating a maintenance window that overlaps with an existing one for the same monitor, parent group, or global scope. Handles both timed and indefinite windows.
647 lines
18 KiB
Go
647 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
)
|
|
|
|
func newTestStore(t *testing.T) *SQLStore {
|
|
t.Helper()
|
|
s, err := NewSQLiteStore(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteStore: %v", err)
|
|
}
|
|
if err := s.Init(context.Background()); err != nil {
|
|
t.Fatalf("Init: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func TestSiteCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
sites, err := s.GetSites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSites: %v", err)
|
|
}
|
|
if len(sites) != 0 {
|
|
t.Fatalf("expected 0 sites, got %d", len(sites))
|
|
}
|
|
|
|
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Test", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
|
|
t.Fatalf("AddSite: %v", err)
|
|
}
|
|
|
|
sites, err = s.GetSites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSites: %v", err)
|
|
}
|
|
if len(sites) != 1 {
|
|
t.Fatalf("expected 1 site, got %d", len(sites))
|
|
}
|
|
if sites[0].Name != "Test" {
|
|
t.Errorf("expected name 'Test', got '%s'", sites[0].Name)
|
|
}
|
|
|
|
sites[0].Name = "Updated"
|
|
if err := s.UpdateSite(context.Background(), sites[0]); err != nil {
|
|
t.Fatalf("UpdateSite: %v", err)
|
|
}
|
|
|
|
sites, err = s.GetSites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSites: %v", err)
|
|
}
|
|
if sites[0].Name != "Updated" {
|
|
t.Errorf("expected name 'Updated', got '%s'", sites[0].Name)
|
|
}
|
|
|
|
if err := s.DeleteSite(context.Background(), sites[0].ID); err != nil {
|
|
t.Fatalf("DeleteSite: %v", err)
|
|
}
|
|
|
|
sites, err = s.GetSites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSites: %v", err)
|
|
}
|
|
if len(sites) != 0 {
|
|
t.Fatalf("expected 0 sites after delete, got %d", len(sites))
|
|
}
|
|
}
|
|
|
|
func TestAlertCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
|
|
t.Fatalf("AddAlert: %v", err)
|
|
}
|
|
|
|
alerts, err := s.GetAllAlerts(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllAlerts: %v", err)
|
|
}
|
|
if len(alerts) != 1 {
|
|
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
|
}
|
|
if alerts[0].Type != "discord" {
|
|
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
|
|
}
|
|
if alerts[0].Settings["url"] != "https://example.com/hook" {
|
|
t.Errorf("settings url mismatch")
|
|
}
|
|
|
|
a, err := s.GetAlert(context.Background(), alerts[0].ID)
|
|
if err != nil {
|
|
t.Fatalf("GetAlert: %v", err)
|
|
}
|
|
if a.Name != "Discord" {
|
|
t.Errorf("expected name 'Discord', got '%s'", a.Name)
|
|
}
|
|
|
|
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
|
|
t.Fatalf("UpdateAlert: %v", err)
|
|
}
|
|
|
|
a, err = s.GetAlert(context.Background(), a.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetAlert: %v", err)
|
|
}
|
|
if a.Type != "slack" {
|
|
t.Errorf("expected type 'slack', got '%s'", a.Type)
|
|
}
|
|
|
|
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
|
|
t.Fatalf("DeleteAlert: %v", err)
|
|
}
|
|
|
|
alerts, err = s.GetAllAlerts(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllAlerts: %v", err)
|
|
}
|
|
if len(alerts) != 0 {
|
|
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
|
|
}
|
|
}
|
|
|
|
func TestUserCRUD(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
if err := s.AddUser(context.Background(), "admin", "ssh-ed25519 AAAA...", "admin"); err != nil {
|
|
t.Fatalf("AddUser: %v", err)
|
|
}
|
|
|
|
users, err := s.GetAllUsers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllUsers: %v", err)
|
|
}
|
|
if len(users) != 1 {
|
|
t.Fatalf("expected 1 user, got %d", len(users))
|
|
}
|
|
if users[0].Username != "admin" {
|
|
t.Errorf("expected username 'admin', got '%s'", users[0].Username)
|
|
}
|
|
|
|
if err := s.UpdateUser(context.Background(), users[0].ID, "root", "ssh-ed25519 BBBB...", "admin"); err != nil {
|
|
t.Fatalf("UpdateUser: %v", err)
|
|
}
|
|
|
|
users, err = s.GetAllUsers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllUsers: %v", err)
|
|
}
|
|
if users[0].Username != "root" {
|
|
t.Errorf("expected username 'root', got '%s'", users[0].Username)
|
|
}
|
|
|
|
if err := s.DeleteUser(context.Background(), users[0].ID); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
|
|
users, err = s.GetAllUsers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllUsers: %v", err)
|
|
}
|
|
if len(users) != 0 {
|
|
t.Fatalf("expected 0 users after delete, got %d", len(users))
|
|
}
|
|
}
|
|
|
|
func TestPushTokenGeneration(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Push Monitor", Type: "push", Interval: 60}); err != nil {
|
|
t.Fatalf("AddSite: %v", err)
|
|
}
|
|
|
|
sites, err := s.GetSites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSites: %v", err)
|
|
}
|
|
if len(sites) != 1 {
|
|
t.Fatalf("expected 1 site, got %d", len(sites))
|
|
}
|
|
if sites[0].Token == "" {
|
|
t.Error("expected non-empty token for push monitor")
|
|
}
|
|
if len(sites[0].Token) != 32 {
|
|
t.Errorf("expected 32-char hex token, got %d chars", len(sites[0].Token))
|
|
}
|
|
}
|
|
|
|
func TestImportExport(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
if err := s.AddAlert(context.Background(), "Test Alert", "webhook", map[string]string{"url": "https://example.com"}); err != nil {
|
|
t.Fatalf("AddAlert: %v", err)
|
|
}
|
|
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "Site1", URL: "https://example.com", Type: "http", Interval: 30}); err != nil {
|
|
t.Fatalf("AddSite: %v", err)
|
|
}
|
|
if err := s.AddUser(context.Background(), "user1", "ssh-ed25519 KEY", "user"); err != nil {
|
|
t.Fatalf("AddUser: %v", err)
|
|
}
|
|
|
|
backup, err := s.ExportData(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("ExportData: %v", err)
|
|
}
|
|
if len(backup.Sites) != 1 || len(backup.Alerts) != 1 || len(backup.Users) != 1 {
|
|
t.Fatalf("export mismatch: %d sites, %d alerts, %d users", len(backup.Sites), len(backup.Alerts), len(backup.Users))
|
|
}
|
|
|
|
s2 := newTestStore(t)
|
|
if err := s2.ImportData(context.Background(), backup); err != nil {
|
|
t.Fatalf("ImportData: %v", err)
|
|
}
|
|
|
|
sites, err := s2.GetSites(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetSites: %v", err)
|
|
}
|
|
alerts, err := s2.GetAllAlerts(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllAlerts: %v", err)
|
|
}
|
|
users, err := s2.GetAllUsers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllUsers: %v", err)
|
|
}
|
|
if len(sites) != 1 || len(alerts) != 1 || len(users) != 1 {
|
|
t.Fatalf("import mismatch: %d sites, %d alerts, %d users", len(sites), len(alerts), len(users))
|
|
}
|
|
}
|
|
|
|
func TestImportData_WipesHistory(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
if err := s.AddSite(context.Background(), models.SiteConfig{Name: "OldSite", URL: "https://old.com", Type: "http", Interval: 30}); err != nil {
|
|
t.Fatalf("AddSite: %v", err)
|
|
}
|
|
if err := s.SaveCheck(context.Background(), 1, 5000, true); err != nil {
|
|
t.Fatalf("SaveCheck: %v", err)
|
|
}
|
|
if err := s.SaveStateChange(context.Background(), 1, "UP", "DOWN", "timeout"); err != nil {
|
|
t.Fatalf("SaveStateChange: %v", err)
|
|
}
|
|
if err := s.SaveAlertHealth(context.Background(), models.AlertHealthRecord{AlertID: 1, LastSendOK: true, SendCount: 1}); err != nil {
|
|
t.Fatalf("SaveAlertHealth: %v", err)
|
|
}
|
|
|
|
backup := models.Backup{
|
|
Sites: []models.SiteConfig{{ID: 1, Name: "NewSite", URL: "https://new.com", Type: "http", Interval: 60}},
|
|
}
|
|
if err := s.ImportData(context.Background(), backup); err != nil {
|
|
t.Fatalf("ImportData: %v", err)
|
|
}
|
|
|
|
history, err := s.LoadAllHistory(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatalf("LoadAllHistory: %v", err)
|
|
}
|
|
if len(history) != 0 {
|
|
t.Errorf("expected empty check_history after import, got %d sites with history", len(history))
|
|
}
|
|
|
|
changes, err := s.GetStateChanges(context.Background(), 1, 100)
|
|
if err != nil {
|
|
t.Fatalf("GetStateChanges: %v", err)
|
|
}
|
|
if len(changes) != 0 {
|
|
t.Errorf("expected empty state_changes after import, got %d", len(changes))
|
|
}
|
|
}
|
|
|
|
func TestImportData_NilUsersPreservesExisting(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
if err := s.AddUser(context.Background(), "admin", "ssh-ed25519 ADMINKEY", "admin"); err != nil {
|
|
t.Fatalf("AddUser: %v", err)
|
|
}
|
|
|
|
backup := models.Backup{
|
|
Sites: []models.SiteConfig{{ID: 1, Name: "New", URL: "https://new.com", Type: "http", Interval: 30}},
|
|
Alerts: []models.AlertConfig{{ID: 1, Name: "a", Type: "webhook", Settings: map[string]string{"url": "https://h.com"}}},
|
|
Users: nil,
|
|
}
|
|
if err := s.ImportData(context.Background(), backup); err != nil {
|
|
t.Fatalf("ImportData: %v", err)
|
|
}
|
|
|
|
users, err := s.GetAllUsers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllUsers: %v", err)
|
|
}
|
|
if len(users) != 1 || users[0].Username != "admin" {
|
|
t.Errorf("expected existing admin user preserved, got %d users", len(users))
|
|
}
|
|
}
|
|
|
|
func TestCheckHistory(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
|
|
t.Fatalf("SaveCheck: %v", err)
|
|
}
|
|
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
|
|
t.Fatalf("SaveCheck: %v", err)
|
|
}
|
|
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
|
|
t.Fatalf("SaveCheck site 2: %v", err)
|
|
}
|
|
|
|
history, err := s.LoadAllHistory(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("LoadAllHistory: %v", err)
|
|
}
|
|
if len(history[1]) != 2 {
|
|
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
|
|
}
|
|
if len(history[2]) != 1 {
|
|
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
|
|
}
|
|
|
|
upCount := 0
|
|
for _, r := range history[1] {
|
|
if r.IsUp {
|
|
upCount++
|
|
}
|
|
}
|
|
if upCount != 1 {
|
|
t.Errorf("expected 1 up record for site 1, got %d", upCount)
|
|
}
|
|
}
|
|
|
|
func TestDeleteSiteCascade(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
|
|
if err := s.AddSite(context.Background(), site); err != nil {
|
|
t.Fatalf("AddSite: %v", err)
|
|
}
|
|
sites, _ := s.GetSites(context.Background())
|
|
siteID := sites[0].ID
|
|
|
|
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
|
|
t.Fatalf("SaveCheck: %v", err)
|
|
}
|
|
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
|
|
t.Fatalf("SaveStateChange: %v", err)
|
|
}
|
|
mw := models.MaintenanceWindow{
|
|
MonitorID: siteID,
|
|
Title: "Test MW",
|
|
Type: "maintenance",
|
|
StartTime: time.Now(),
|
|
}
|
|
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
|
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
}
|
|
|
|
if err := s.DeleteSite(context.Background(), siteID); err != nil {
|
|
t.Fatalf("DeleteSite: %v", err)
|
|
}
|
|
|
|
history, _ := s.LoadAllHistory(context.Background(), 100)
|
|
if len(history[siteID]) != 0 {
|
|
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
|
|
}
|
|
|
|
changes, _ := s.GetStateChanges(context.Background(), siteID, 100)
|
|
if len(changes) != 0 {
|
|
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
|
|
}
|
|
|
|
windows, _ := s.GetActiveMaintenanceWindows(context.Background())
|
|
for _, w := range windows {
|
|
if w.MonitorID == siteID {
|
|
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPruneLogs(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
for i := 0; i < maxLogRows+50; i++ {
|
|
if err := s.SaveLog(context.Background(), fmt.Sprintf("log %d", i)); err != nil {
|
|
t.Fatalf("SaveLog: %v", err)
|
|
}
|
|
}
|
|
if err := s.PruneLogs(context.Background()); err != nil {
|
|
t.Fatalf("PruneLogs: %v", err)
|
|
}
|
|
|
|
logs, err := s.LoadLogs(context.Background(), maxLogRows*2)
|
|
if err != nil {
|
|
t.Fatalf("LoadLogs: %v", err)
|
|
}
|
|
if len(logs) != maxLogRows {
|
|
t.Errorf("expected %d logs after prune, got %d", maxLogRows, len(logs))
|
|
}
|
|
// Newest must survive; oldest must be gone (membership, not position —
|
|
// LoadLogs ordering ties when rows share a created_at second).
|
|
present := make(map[string]bool, len(logs))
|
|
for _, l := range logs {
|
|
present[l.Message] = true
|
|
}
|
|
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
|
|
t.Error("newest log was pruned")
|
|
}
|
|
if present["log 0"] {
|
|
t.Error("oldest log survived prune")
|
|
}
|
|
}
|
|
|
|
func TestPruneCheckHistory(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
for i := 0; i < maxCheckHistory+5; i++ {
|
|
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
|
|
t.Fatalf("SaveCheck site 1: %v", err)
|
|
}
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
|
|
t.Fatalf("SaveCheck site 2: %v", err)
|
|
}
|
|
}
|
|
|
|
if err := s.PruneCheckHistory(context.Background()); err != nil {
|
|
t.Fatalf("PruneCheckHistory: %v", err)
|
|
}
|
|
|
|
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
|
|
if err != nil {
|
|
t.Fatalf("LoadAllHistory: %v", err)
|
|
}
|
|
if len(history[1]) != maxCheckHistory {
|
|
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
|
|
}
|
|
if len(history[2]) != 3 {
|
|
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
|
|
}
|
|
}
|
|
|
|
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
|
|
s := newTestStore(t)
|
|
|
|
now := time.Now()
|
|
|
|
// Expired 10 days ago — should be pruned with 7d retention.
|
|
old := models.MaintenanceWindow{
|
|
MonitorID: 0,
|
|
Title: "Old Window",
|
|
Type: "maintenance",
|
|
StartTime: now.Add(-11 * 24 * time.Hour),
|
|
EndTime: now.Add(-10 * 24 * time.Hour),
|
|
}
|
|
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
|
|
t.Fatalf("AddMaintenanceWindow (old): %v", err)
|
|
}
|
|
|
|
// Expired 1 day ago — within 7d retention, should survive.
|
|
recent := models.MaintenanceWindow{
|
|
MonitorID: 0,
|
|
Title: "Recent Window",
|
|
Type: "maintenance",
|
|
StartTime: now.Add(-2 * 24 * time.Hour),
|
|
EndTime: now.Add(-1 * 24 * time.Hour),
|
|
}
|
|
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
|
|
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
|
|
}
|
|
|
|
// Ongoing — no end time, should survive.
|
|
ongoing := models.MaintenanceWindow{
|
|
MonitorID: 0,
|
|
Title: "Ongoing Window",
|
|
Type: "maintenance",
|
|
StartTime: now.Add(-1 * time.Hour),
|
|
}
|
|
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
|
|
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
|
|
}
|
|
|
|
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
|
|
}
|
|
if pruned != 1 {
|
|
t.Errorf("expected 1 pruned, got %d", pruned)
|
|
}
|
|
|
|
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
|
|
if err != nil {
|
|
t.Fatalf("GetAllMaintenanceWindows: %v", err)
|
|
}
|
|
if len(all) != 2 {
|
|
t.Fatalf("expected 2 remaining windows, got %d", len(all))
|
|
}
|
|
for _, w := range all {
|
|
if w.Title == "Old Window" {
|
|
t.Error("old window should have been pruned")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGetOverlappingMaintenanceWindows(t *testing.T) {
|
|
s := newTestStore(t)
|
|
ctx := context.Background()
|
|
|
|
site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30}
|
|
if err := s.AddSite(ctx, site); err != nil {
|
|
t.Fatalf("AddSite: %v", err)
|
|
}
|
|
|
|
now := time.Now()
|
|
|
|
active := models.MaintenanceWindow{
|
|
MonitorID: 1,
|
|
Title: "Deploy v2",
|
|
Type: "maintenance",
|
|
StartTime: now.Add(-30 * time.Minute),
|
|
EndTime: now.Add(30 * time.Minute),
|
|
}
|
|
if err := s.AddMaintenanceWindow(ctx, active); err != nil {
|
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
}
|
|
|
|
ended := models.MaintenanceWindow{
|
|
MonitorID: 1,
|
|
Title: "Old deploy",
|
|
Type: "maintenance",
|
|
StartTime: now.Add(-3 * time.Hour),
|
|
EndTime: now.Add(-2 * time.Hour),
|
|
}
|
|
if err := s.AddMaintenanceWindow(ctx, ended); err != nil {
|
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
}
|
|
|
|
t.Run("same monitor overlaps", func(t *testing.T) {
|
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(overlaps) != 1 {
|
|
t.Fatalf("expected 1 overlap, got %d", len(overlaps))
|
|
}
|
|
if overlaps[0].Title != "Deploy v2" {
|
|
t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title)
|
|
}
|
|
})
|
|
|
|
t.Run("different monitor no overlap", func(t *testing.T) {
|
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(overlaps) != 0 {
|
|
t.Errorf("expected 0 overlaps, got %d", len(overlaps))
|
|
}
|
|
})
|
|
|
|
t.Run("global window overlaps all", func(t *testing.T) {
|
|
global := models.MaintenanceWindow{
|
|
MonitorID: 0,
|
|
Title: "Global freeze",
|
|
Type: "maintenance",
|
|
StartTime: now.Add(-10 * time.Minute),
|
|
EndTime: now.Add(2 * time.Hour),
|
|
}
|
|
if err := s.AddMaintenanceWindow(ctx, global); err != nil {
|
|
t.Fatalf("AddMaintenanceWindow: %v", err)
|
|
}
|
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(overlaps) != 2 {
|
|
t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps))
|
|
}
|
|
})
|
|
|
|
t.Run("indefinite window overlaps", func(t *testing.T) {
|
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(overlaps) < 1 {
|
|
t.Error("expected at least 1 overlap for indefinite window")
|
|
}
|
|
})
|
|
|
|
t.Run("ended window excluded", func(t *testing.T) {
|
|
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(overlaps) != 0 {
|
|
t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps))
|
|
}
|
|
})
|
|
}
|
|
|
|
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
|
|
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
|
|
func TestImportData_EncryptsAlertSettings(t *testing.T) {
|
|
s := newTestStore(t)
|
|
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
|
|
if err != nil {
|
|
t.Fatalf("NewEncryptor: %v", err)
|
|
}
|
|
s.SetEncryptor(enc)
|
|
|
|
backup := models.Backup{
|
|
Alerts: []models.AlertConfig{
|
|
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
|
|
},
|
|
}
|
|
if err := s.ImportData(context.Background(), backup); err != nil {
|
|
t.Fatalf("ImportData: %v", err)
|
|
}
|
|
|
|
var raw string
|
|
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
|
|
t.Fatalf("query settings: %v", err)
|
|
}
|
|
if !strings.HasPrefix(raw, encryptedPrefix) {
|
|
t.Errorf("imported settings not encrypted: %q", raw)
|
|
}
|
|
if strings.Contains(raw, "SECRET") {
|
|
t.Errorf("plaintext secret found in stored column: %q", raw)
|
|
}
|
|
|
|
alerts, err := s.GetAllAlerts(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("GetAllAlerts: %v", err)
|
|
}
|
|
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
|
|
t.Errorf("decrypt round-trip failed: %+v", alerts)
|
|
}
|
|
}
|