package store import ( "context" "path/filepath" "testing" "time" "github.com/oklog/ulid/v2" ) func newTestStoreWithHost(t *testing.T) (*Store, string) { t.Helper() dir := t.TempDir() st, err := Open(context.Background(), filepath.Join(dir, "rm.db")) if err != nil { t.Fatalf("open: %v", err) } t.Cleanup(func() { _ = st.Close() }) hostID := ulid.Make().String() if err := st.CreateHost(context.Background(), Host{ ID: hostID, Name: "h", OS: "linux", Arch: "amd64", EnrolledAt: time.Now().UTC(), }, "deadbeef", ""); err != nil { t.Fatalf("create host: %v", err) } return st, hostID } func TestRaiseOrTouchInsertsThenTouches(t *testing.T) { t.Parallel() st, hostID := newTestStoreWithHost(t) ctx := context.Background() t0 := time.Now().UTC() id1, didRaise, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "", "warning", "Backup failed: 401", t0) if err != nil { t.Fatalf("first raise: %v", err) } if !didRaise { t.Fatalf("first call must didRaise=true") } if id1 == "" { t.Fatalf("expected non-empty id") } // Second call within the same open window should touch, not insert. t1 := t0.Add(60 * time.Second) id2, didRaise2, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "", "warning", "Backup failed: 401 (still)", t1) if err != nil { t.Fatalf("touch: %v", err) } if didRaise2 { t.Fatalf("second call must didRaise=false") } if id2 != id1 { t.Fatalf("touch returned a different id: got %q want %q", id2, id1) } // last_seen_at and message must be updated. got, err := st.GetAlert(ctx, id1) if err != nil { t.Fatalf("get: %v", err) } if got.LastSeenAt == nil || !got.LastSeenAt.Equal(t1) { t.Errorf("last_seen_at: got %v want %v", got.LastSeenAt, t1) } if got.Message != "Backup failed: 401 (still)" { t.Errorf("message not refreshed: %q", got.Message) } } func TestResolveAndReRaiseStartsFreshAlert(t *testing.T) { t.Parallel() st, hostID := newTestStoreWithHost(t) ctx := context.Background() t0 := time.Now().UTC() id1, _, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "", "warning", "first", t0) if err != nil { t.Fatalf("raise: %v", err) } if err := st.Resolve(ctx, id1, t0.Add(time.Minute)); err != nil { t.Fatalf("resolve: %v", err) } id2, didRaise, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "", "warning", "second", t0.Add(2*time.Minute)) if err != nil { t.Fatalf("re-raise: %v", err) } if !didRaise { t.Fatalf("post-resolve raise must didRaise=true") } if id2 == id1 { t.Fatalf("re-raise reused the resolved id; want a fresh row") } } // Two source groups failing on the same host produce two distinct // open alerts (not one collapsed). Pre-dedup-key, this would have // touched the existing row and silently dropped the second failure. func TestRaiseOrTouchDedupsPerSourceGroup(t *testing.T) { t.Parallel() st, hostID := newTestStoreWithHost(t) ctx := context.Background() t0 := time.Now().UTC() idA, didA, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "group-a", "warning", "group A failed", t0) if err != nil || !didA { t.Fatalf("group A raise: id=%q didRaise=%v err=%v", idA, didA, err) } idB, didB, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "group-b", "warning", "group B failed", t0.Add(time.Second)) if err != nil || !didB { t.Fatalf("group B raise: id=%q didRaise=%v err=%v", idB, didB, err) } if idA == idB { t.Fatalf("expected distinct alert ids per source group, got %q twice", idA) } // Resolving group A must not auto-resolve group B. if err := st.AutoResolve(ctx, hostID, "backup_failed", "group-a", t0.Add(2*time.Second)); err != nil { t.Fatalf("auto-resolve A: %v", err) } gotB, _ := st.GetAlert(ctx, idB) if gotB.ResolvedAt != nil { t.Errorf("group B got auto-resolved by group A's recovery; resolved_at=%v", gotB.ResolvedAt) } } func TestAcknowledgeKeepsAlertOpen(t *testing.T) { t.Parallel() st, hostID := newTestStoreWithHost(t) ctx := context.Background() // Create a real user so the acknowledged_by FK is satisfied. userID := ulid.Make().String() if err := st.CreateUser(ctx, User{ ID: userID, Username: "ackuser", PasswordHash: "x", Role: RoleOperator, CreatedAt: time.Now().UTC(), }); err != nil { t.Fatalf("create user: %v", err) } id, _, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "", "warning", "m", time.Now().UTC()) if err != nil { t.Fatalf("raise: %v", err) } if err := st.Acknowledge(ctx, id, userID, time.Now().UTC()); err != nil { t.Fatalf("ack: %v", err) } got, err := st.GetAlert(ctx, id) if err != nil { t.Fatalf("get: %v", err) } if got.AcknowledgedAt == nil { t.Errorf("acknowledged_at not set") } if got.AcknowledgedBy == nil || *got.AcknowledgedBy != userID { t.Errorf("acknowledged_by: got %v want %q", got.AcknowledgedBy, userID) } if got.ResolvedAt != nil { t.Errorf("ack must not set resolved_at; got %v", got.ResolvedAt) } } func TestAutoResolveClearsOpenAlerts(t *testing.T) { t.Parallel() st, hostID := newTestStoreWithHost(t) ctx := context.Background() t0 := time.Now().UTC() id, _, _ := st.RaiseOrTouch(ctx, hostID, "backup_failed", "", "warning", "m", t0) if err := st.AutoResolve(ctx, hostID, "backup_failed", "", t0.Add(time.Minute)); err != nil { t.Fatalf("auto-resolve: %v", err) } got, _ := st.GetAlert(ctx, id) if got.ResolvedAt == nil { t.Errorf("expected resolved_at set") } } func TestListAlertsFilters(t *testing.T) { t.Parallel() st, hostID := newTestStoreWithHost(t) ctx := context.Background() t0 := time.Now().UTC() // One open warning + one resolved info. _, _, _ = st.RaiseOrTouch(ctx, hostID, "backup_failed", "", "warning", "open", t0) id2, _, _ := st.RaiseOrTouch(ctx, hostID, "stale_schedule", "", "info", "done", t0) _ = st.Resolve(ctx, id2, t0.Add(time.Minute)) open, err := st.ListAlerts(ctx, AlertFilter{Status: "open"}) if err != nil { t.Fatalf("list open: %v", err) } if len(open) != 1 || open[0].Severity != "warning" { t.Errorf("open filter: got %+v", open) } all, err := st.ListAlerts(ctx, AlertFilter{Status: "all"}) if err != nil { t.Fatalf("list all: %v", err) } if len(all) != 2 { t.Errorf("all filter: got %d, want 2", len(all)) } }