mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
e22113230a
The previous rip retired only active "Active alert detected" findings from the now-removed detectAlertSignals -> SignalActiveAlert emitter. Resolved instances were left in place, polluting the Resolved tab and inflating the regressed total on the trust strip (a stale 8 resources each marked "regressed 3x" with descriptions like "Active warning alert: Container 'ollama' is powered off"). They have no canonical operator value -- the Alerts surface is the source of truth for currently-firing alerts -- so on load we now purge them entirely rather than keeping them around as Resolved noise. Active mirrors are still retired (auto-resolved with a clear reason) so operators see why the finding closed; resolved mirrors disappear silently because they were already in the terminal state. Idempotent. Extends TestFindingsStore_SetPersistence_RetiresLegacyAlertMirrorFindings with a fixture for the resolved-mirror case and asserts both the in-memory purge and the persisted state no longer carries it.
840 lines
24 KiB
Go
840 lines
24 KiB
Go
package ai
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type recordingPersistence struct {
|
|
findings map[string]*Finding
|
|
loadErr error
|
|
saveErr error
|
|
saved chan map[string]*Finding
|
|
}
|
|
|
|
func (p *recordingPersistence) SaveFindings(findings map[string]*Finding) error {
|
|
if p.saved != nil {
|
|
p.saved <- findings
|
|
}
|
|
return p.saveErr
|
|
}
|
|
|
|
func (p *recordingPersistence) LoadFindings() (map[string]*Finding, error) {
|
|
if p.loadErr != nil {
|
|
return nil, p.loadErr
|
|
}
|
|
if p.findings == nil {
|
|
return make(map[string]*Finding), nil
|
|
}
|
|
return p.findings, nil
|
|
}
|
|
|
|
func TestFindingsStore_SetPersistence_LoadError(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
loadErr := errors.New("load error")
|
|
|
|
err := store.SetPersistence(&recordingPersistence{loadErr: loadErr})
|
|
if !errors.Is(err, loadErr) {
|
|
t.Fatalf("expected load error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_SetPersistence_LoadsFindings(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
now := time.Now()
|
|
resolvedAt := now.Add(-time.Hour)
|
|
|
|
p := &recordingPersistence{
|
|
findings: map[string]*Finding{
|
|
"active": {
|
|
ID: "active",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "res-1",
|
|
Title: "Active",
|
|
LastSeenAt: now,
|
|
},
|
|
"resolved": {
|
|
ID: "resolved",
|
|
Severity: FindingSeverityCritical,
|
|
ResourceID: "res-2",
|
|
Title: "Resolved",
|
|
LastSeenAt: now,
|
|
ResolvedAt: &resolvedAt,
|
|
},
|
|
},
|
|
}
|
|
|
|
if err := store.SetPersistence(p); err != nil {
|
|
t.Fatalf("SetPersistence failed: %v", err)
|
|
}
|
|
|
|
if store.activeCounts[FindingSeverityWarning] != 1 {
|
|
t.Errorf("expected active warning count 1, got %d", store.activeCounts[FindingSeverityWarning])
|
|
}
|
|
if len(store.byResource["res-1"]) != 1 {
|
|
t.Errorf("expected resource index for res-1 to have 1 entry, got %d", len(store.byResource["res-1"]))
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_SetPersistence_NormalizesRegressedAcknowledgementState(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveDebounce = 5 * time.Millisecond
|
|
now := time.Now()
|
|
acknowledgedAt := now.Add(-2 * time.Hour)
|
|
lastRegressionAt := now.Add(-time.Hour)
|
|
saved := make(chan map[string]*Finding, 1)
|
|
|
|
p := &recordingPersistence{
|
|
findings: map[string]*Finding{
|
|
"regressed": {
|
|
ID: "regressed",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "res-3",
|
|
Title: "Regressed",
|
|
LastSeenAt: now,
|
|
AcknowledgedAt: &acknowledgedAt,
|
|
LastRegressionAt: &lastRegressionAt,
|
|
},
|
|
},
|
|
saved: saved,
|
|
}
|
|
|
|
if err := store.SetPersistence(p); err != nil {
|
|
t.Fatalf("SetPersistence failed: %v", err)
|
|
}
|
|
|
|
got := store.Get("regressed")
|
|
if got == nil {
|
|
t.Fatal("expected finding to load")
|
|
}
|
|
if got.AcknowledgedAt != nil {
|
|
t.Fatal("expected stale acknowledgement to be cleared on load")
|
|
}
|
|
|
|
select {
|
|
case persisted := <-saved:
|
|
if persisted["regressed"].AcknowledgedAt != nil {
|
|
t.Fatal("expected normalized acknowledgement state to be persisted")
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("timed out waiting for normalized findings save")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_SetPersistence_RetiresLegacyAlertMirrorFindings(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveDebounce = 5 * time.Millisecond
|
|
now := time.Now()
|
|
saved := make(chan map[string]*Finding, 1)
|
|
|
|
resolvedAt := now.Add(-13 * time.Hour)
|
|
p := &recordingPersistence{
|
|
findings: map[string]*Finding{
|
|
// Active "Active alert detected" finding from the removed
|
|
// SignalActiveAlert path — must be retired on load.
|
|
"legacy-mirror": {
|
|
ID: "legacy-mirror",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-100",
|
|
Title: "Active alert detected",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryGeneral,
|
|
LastSeenAt: now,
|
|
},
|
|
// Already-resolved legacy mirror finding — must be purged
|
|
// from the in-memory store and from disk on next save.
|
|
// These have no canonical operator value and clutter the
|
|
// Resolved tab / inflate the regressed totals on the trust
|
|
// strip if left in place.
|
|
"legacy-mirror-resolved": {
|
|
ID: "legacy-mirror-resolved",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-101",
|
|
Title: "Active alert detected",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryGeneral,
|
|
LastSeenAt: resolvedAt,
|
|
ResolvedAt: &resolvedAt,
|
|
AutoResolved: true,
|
|
RegressionCount: 3,
|
|
},
|
|
// Distinct finding with matching title but different source —
|
|
// should NOT be retired (defensive: never retire something we
|
|
// can't positively identify as the alert-mirror artifact).
|
|
"foreign-title-match": {
|
|
ID: "foreign-title-match",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-200",
|
|
Title: "Active alert detected",
|
|
Source: "operator",
|
|
Category: FindingCategoryGeneral,
|
|
LastSeenAt: now,
|
|
},
|
|
// Different finding — must remain active untouched.
|
|
"unrelated": {
|
|
ID: "unrelated",
|
|
Severity: FindingSeverityCritical,
|
|
ResourceID: "vm-300",
|
|
Title: "Disk nearly full",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryCapacity,
|
|
LastSeenAt: now,
|
|
},
|
|
},
|
|
saved: saved,
|
|
}
|
|
|
|
if err := store.SetPersistence(p); err != nil {
|
|
t.Fatalf("SetPersistence failed: %v", err)
|
|
}
|
|
|
|
mirror := store.Get("legacy-mirror")
|
|
if mirror == nil {
|
|
t.Fatal("expected legacy mirror finding to load")
|
|
}
|
|
if mirror.ResolvedAt == nil {
|
|
t.Fatal("legacy mirror finding must be retired (ResolvedAt set) on load")
|
|
}
|
|
if !mirror.AutoResolved {
|
|
t.Fatal("legacy mirror finding must be marked AutoResolved")
|
|
}
|
|
if mirror.ResolveReason == "" {
|
|
t.Fatal("legacy mirror finding must carry a retirement reason")
|
|
}
|
|
if mirror.IsActive() {
|
|
t.Fatal("legacy mirror finding must not stay active")
|
|
}
|
|
foundAutoResolvedEvent := false
|
|
for _, e := range mirror.Lifecycle {
|
|
if e.Type == "auto_resolved" {
|
|
foundAutoResolvedEvent = true
|
|
break
|
|
}
|
|
}
|
|
if !foundAutoResolvedEvent {
|
|
t.Fatal("legacy mirror retirement must append an auto_resolved lifecycle event")
|
|
}
|
|
|
|
// Already-resolved legacy mirror must be purged entirely. The
|
|
// public Get path applies suppression but Get on a non-existent ID
|
|
// returns nil; that's the contract we want.
|
|
if got := store.Get("legacy-mirror-resolved"); got != nil {
|
|
t.Fatalf("resolved legacy mirror must be purged from the store on load; got %+v", got)
|
|
}
|
|
|
|
foreign := store.Get("foreign-title-match")
|
|
if foreign == nil {
|
|
t.Fatal("expected foreign-title-match to load")
|
|
}
|
|
if foreign.ResolvedAt != nil {
|
|
t.Fatalf("foreign-title-match must not be retired by the alert-mirror migration; resolved at %s", foreign.ResolvedAt)
|
|
}
|
|
|
|
unrelated := store.Get("unrelated")
|
|
if unrelated == nil {
|
|
t.Fatal("expected unrelated finding to load")
|
|
}
|
|
if unrelated.ResolvedAt != nil {
|
|
t.Fatal("unrelated finding must not be retired")
|
|
}
|
|
|
|
select {
|
|
case persisted := <-saved:
|
|
if persisted["legacy-mirror"].ResolvedAt == nil {
|
|
t.Fatal("retired mirror state must be persisted back")
|
|
}
|
|
if _, stillPersisted := persisted["legacy-mirror-resolved"]; stillPersisted {
|
|
t.Fatal("purged resolved legacy mirror must not be persisted back to disk")
|
|
}
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("timed out waiting for retirement save")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_SetPersistence_ResetsRegressionCounterPollutedByBogusCycles(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveDebounce = 5 * time.Millisecond
|
|
now := time.Now()
|
|
lastRegress := now.Add(-30 * time.Minute)
|
|
saved := make(chan map[string]*Finding, 1)
|
|
|
|
// Non-eligible category (backup) + auto_resolved on lifecycle + active
|
|
// + regression count > 0 → reset. The auto_resolved messages cover
|
|
// both the empty-message LLM-tool path and the legacy bogus-signature
|
|
// reasons.
|
|
withLLMCycle := &Finding{
|
|
ID: "with-llm-cycle",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-llm",
|
|
Title: "Backup failed",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryBackup,
|
|
LastSeenAt: now,
|
|
RegressionCount: 6,
|
|
LastRegressionAt: &lastRegress,
|
|
Lifecycle: []FindingLifecycleEvent{
|
|
{At: now.Add(-3 * time.Hour), Type: "detected"},
|
|
// Empty message → Resolve(_, true), the LLM patrol_resolve_finding tool.
|
|
{At: now.Add(-2 * time.Hour), Type: "auto_resolved"},
|
|
{At: now.Add(-90 * time.Minute), Type: "regressed"},
|
|
{At: now.Add(-60 * time.Minute), Type: "auto_resolved"},
|
|
{At: lastRegress, Type: "regressed"},
|
|
},
|
|
}
|
|
|
|
withLegacyReasonCycle := &Finding{
|
|
ID: "with-legacy-reason",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-legacy",
|
|
Title: "Service crashed",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryReliability,
|
|
LastSeenAt: now,
|
|
RegressionCount: 3,
|
|
LastRegressionAt: &lastRegress,
|
|
Lifecycle: []FindingLifecycleEvent{
|
|
{At: now.Add(-3 * time.Hour), Type: "detected"},
|
|
{At: now.Add(-2 * time.Hour), Type: "auto_resolved", Message: "No longer detected by patrol"},
|
|
{At: lastRegress, Type: "regressed"},
|
|
},
|
|
}
|
|
|
|
// Eligible category (capacity) — the absence-driven resolution model
|
|
// is sound here, so the counter is trustworthy and must be preserved
|
|
// even if auto_resolved events appear on the lifecycle.
|
|
withEligibleCategory := &Finding{
|
|
ID: "with-eligible",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-eligible",
|
|
Title: "Disk nearly full",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryCapacity,
|
|
LastSeenAt: now,
|
|
RegressionCount: 2,
|
|
LastRegressionAt: &lastRegress,
|
|
Lifecycle: []FindingLifecycleEvent{
|
|
{At: now.Add(-2 * time.Hour), Type: "detected"},
|
|
{At: now.Add(-90 * time.Minute), Type: "auto_resolved"},
|
|
{At: lastRegress, Type: "regressed"},
|
|
},
|
|
}
|
|
|
|
// Active finding on a non-eligible category but no auto_resolved
|
|
// event in lifecycle — counter is genuine, preserve it.
|
|
withoutAutoResolve := &Finding{
|
|
ID: "without-auto",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-clean",
|
|
Title: "Service crashed",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryReliability,
|
|
LastSeenAt: now,
|
|
RegressionCount: 1,
|
|
LastRegressionAt: &lastRegress,
|
|
Lifecycle: []FindingLifecycleEvent{
|
|
{At: now.Add(-2 * time.Hour), Type: "detected"},
|
|
{At: now.Add(-90 * time.Minute), Type: "resolved"},
|
|
{At: lastRegress, Type: "regressed"},
|
|
},
|
|
}
|
|
|
|
alreadyMigrated := &Finding{
|
|
ID: "already",
|
|
Severity: FindingSeverityWarning,
|
|
ResourceID: "vm-already",
|
|
Title: "Backup failed",
|
|
Source: "ai-analysis",
|
|
Category: FindingCategoryBackup,
|
|
LastSeenAt: now,
|
|
RegressionCount: 4,
|
|
LastRegressionAt: &lastRegress,
|
|
Lifecycle: []FindingLifecycleEvent{
|
|
{At: now.Add(-3 * time.Hour), Type: "auto_resolved"},
|
|
{At: now.Add(-2 * time.Hour), Type: "regression_counter_reset"},
|
|
{At: now.Add(-90 * time.Minute), Type: "regressed"},
|
|
},
|
|
}
|
|
|
|
p := &recordingPersistence{
|
|
findings: map[string]*Finding{
|
|
withLLMCycle.ID: withLLMCycle,
|
|
withLegacyReasonCycle.ID: withLegacyReasonCycle,
|
|
withEligibleCategory.ID: withEligibleCategory,
|
|
withoutAutoResolve.ID: withoutAutoResolve,
|
|
alreadyMigrated.ID: alreadyMigrated,
|
|
},
|
|
saved: saved,
|
|
}
|
|
|
|
if err := store.SetPersistence(p); err != nil {
|
|
t.Fatalf("SetPersistence failed: %v", err)
|
|
}
|
|
|
|
for _, id := range []string{"with-llm-cycle", "with-legacy-reason"} {
|
|
f := store.Get(id)
|
|
if f == nil {
|
|
t.Fatalf("expected finding %s to load", id)
|
|
}
|
|
if f.RegressionCount != 0 {
|
|
t.Fatalf("%s: expected counter reset to 0, got %d", id, f.RegressionCount)
|
|
}
|
|
if f.LastRegressionAt != nil {
|
|
t.Fatalf("%s: expected LastRegressionAt cleared", id)
|
|
}
|
|
reset := false
|
|
for _, e := range f.Lifecycle {
|
|
if e.Type == "regression_counter_reset" {
|
|
reset = true
|
|
break
|
|
}
|
|
}
|
|
if !reset {
|
|
t.Fatalf("%s: expected regression_counter_reset lifecycle event", id)
|
|
}
|
|
}
|
|
|
|
for _, id := range []string{"with-eligible", "without-auto"} {
|
|
f := store.Get(id)
|
|
if f == nil {
|
|
t.Fatalf("expected finding %s to load", id)
|
|
}
|
|
if f.RegressionCount == 0 {
|
|
t.Fatalf("%s: counter must not be reset; was reset to 0", id)
|
|
}
|
|
for _, e := range f.Lifecycle {
|
|
if e.Type == "regression_counter_reset" {
|
|
t.Fatalf("%s: must not gain a reset event", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
already := store.Get("already")
|
|
if already.RegressionCount != 4 {
|
|
t.Fatalf("already-migrated finding must not be reset again; got %d", already.RegressionCount)
|
|
}
|
|
resetCount := 0
|
|
for _, e := range already.Lifecycle {
|
|
if e.Type == "regression_counter_reset" {
|
|
resetCount++
|
|
}
|
|
}
|
|
if resetCount != 1 {
|
|
t.Fatalf("already-migrated finding must keep exactly one reset event, got %d", resetCount)
|
|
}
|
|
|
|
select {
|
|
case <-saved:
|
|
case <-time.After(500 * time.Millisecond):
|
|
t.Fatal("timed out waiting for migration save")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_scheduleSave_NoPersistence(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
|
|
store.scheduleSave()
|
|
|
|
if store.saveTimer != nil {
|
|
t.Error("expected no save timer when persistence is nil")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_scheduleSave_SavePending(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.persistence = &recordingPersistence{saved: make(chan map[string]*Finding, 1)}
|
|
store.savePending = true
|
|
|
|
store.scheduleSave()
|
|
|
|
select {
|
|
case <-store.persistence.(*recordingPersistence).saved:
|
|
t.Fatal("save should not run when already pending")
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_scheduleSave_SavesAndSkipsDemo(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveDebounce = 5 * time.Millisecond
|
|
saved := make(chan map[string]*Finding, 1)
|
|
store.persistence = &recordingPersistence{saved: saved}
|
|
|
|
store.findings["demo-1"] = &Finding{ID: "demo-1", Title: "Demo"}
|
|
store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"}
|
|
|
|
store.scheduleSave()
|
|
|
|
select {
|
|
case data := <-saved:
|
|
if _, exists := data["demo-1"]; exists {
|
|
t.Error("demo findings should not be persisted")
|
|
}
|
|
if _, exists := data["real-1"]; !exists {
|
|
t.Error("expected real finding to be persisted")
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("timed out waiting for SaveFindings")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_scheduleSave_SaveError(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveDebounce = 5 * time.Millisecond
|
|
saved := make(chan map[string]*Finding, 1)
|
|
store.persistence = &recordingPersistence{
|
|
saveErr: errors.New("save error"),
|
|
saved: saved,
|
|
}
|
|
|
|
store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"}
|
|
store.scheduleSave()
|
|
|
|
select {
|
|
case <-saved:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("timed out waiting for SaveFindings")
|
|
}
|
|
|
|
// Wait for the error to be recorded
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Check that error is tracked
|
|
lastErr, _, _ := store.GetPersistenceStatus()
|
|
if lastErr == nil {
|
|
t.Error("expected lastSaveError to be set after save failure")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_SetOnSaveError(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveDebounce = 5 * time.Millisecond
|
|
saved := make(chan map[string]*Finding, 1)
|
|
errReceived := make(chan error, 1)
|
|
|
|
store.persistence = &recordingPersistence{
|
|
saveErr: errors.New("callback test error"),
|
|
saved: saved,
|
|
}
|
|
|
|
// Set error callback
|
|
store.SetOnSaveError(func(err error) {
|
|
errReceived <- err
|
|
})
|
|
|
|
store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"}
|
|
store.scheduleSave()
|
|
|
|
select {
|
|
case <-saved:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("timed out waiting for SaveFindings")
|
|
}
|
|
|
|
select {
|
|
case err := <-errReceived:
|
|
if err == nil {
|
|
t.Error("expected error to be passed to callback")
|
|
}
|
|
if err.Error() != "callback test error" {
|
|
t.Errorf("expected 'callback test error', got %v", err)
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("timed out waiting for error callback")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_GetPersistenceStatus_NoPersistence(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
|
|
lastErr, lastSaveTime, hasPersistence := store.GetPersistenceStatus()
|
|
|
|
if lastErr != nil {
|
|
t.Error("expected no error when no persistence")
|
|
}
|
|
if !lastSaveTime.IsZero() {
|
|
t.Error("expected zero time when no persistence")
|
|
}
|
|
if hasPersistence {
|
|
t.Error("expected hasPersistence to be false")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_GetPersistenceStatus_WithPersistence(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveDebounce = 5 * time.Millisecond
|
|
saved := make(chan map[string]*Finding, 1)
|
|
store.persistence = &recordingPersistence{saved: saved}
|
|
|
|
_, _, hasPersistence := store.GetPersistenceStatus()
|
|
if !hasPersistence {
|
|
t.Error("expected hasPersistence to be true")
|
|
}
|
|
|
|
// Trigger a successful save
|
|
store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"}
|
|
store.scheduleSave()
|
|
|
|
select {
|
|
case <-saved:
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("timed out waiting for SaveFindings")
|
|
}
|
|
|
|
// Wait for status to be updated
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
lastErr, lastSaveTime, _ := store.GetPersistenceStatus()
|
|
if lastErr != nil {
|
|
t.Errorf("expected no error after successful save, got %v", lastErr)
|
|
}
|
|
if lastSaveTime.IsZero() {
|
|
t.Error("expected lastSaveTime to be set after successful save")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_ForceSave_NoPersistenceStopsTimer(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.saveTimer = time.AfterFunc(time.Hour, func() {})
|
|
|
|
if err := store.ForceSave(); err != nil {
|
|
t.Fatalf("ForceSave failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_ForceSave_SaveError(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.persistence = &recordingPersistence{saveErr: errors.New("save error")}
|
|
store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"}
|
|
|
|
if err := store.ForceSave(); err == nil {
|
|
t.Fatal("expected ForceSave to return error")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_ForceSave_SkipsDemo(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
saved := make(chan map[string]*Finding, 1)
|
|
store.persistence = &recordingPersistence{saved: saved}
|
|
store.findings["demo-1"] = &Finding{ID: "demo-1", Title: "Demo"}
|
|
store.findings["real-1"] = &Finding{ID: "real-1", Title: "Real"}
|
|
|
|
if err := store.ForceSave(); err != nil {
|
|
t.Fatalf("ForceSave failed: %v", err)
|
|
}
|
|
|
|
select {
|
|
case data := <-saved:
|
|
if _, exists := data["demo-1"]; exists {
|
|
t.Error("demo findings should not be persisted")
|
|
}
|
|
if _, exists := data["real-1"]; !exists {
|
|
t.Error("expected real finding to be persisted")
|
|
}
|
|
case <-time.After(200 * time.Millisecond):
|
|
t.Fatal("timed out waiting for SaveFindings")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_ClearAll(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.Add(&Finding{ID: "f1", Severity: FindingSeverityWarning, ResourceID: "res-1", Title: "Test"})
|
|
store.Add(&Finding{ID: "f2", Severity: FindingSeverityCritical, ResourceID: "res-2", Title: "Test"})
|
|
|
|
count := store.ClearAll()
|
|
|
|
if count != 2 {
|
|
t.Errorf("expected 2 findings cleared, got %d", count)
|
|
}
|
|
if len(store.findings) != 0 {
|
|
t.Error("expected findings map to be empty")
|
|
}
|
|
if len(store.byResource) != 0 {
|
|
t.Error("expected resource index to be empty")
|
|
}
|
|
if len(store.activeCounts) != 0 {
|
|
t.Error("expected active counts to be reset")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_Undismiss(t *testing.T) {
|
|
t.Run("missing", func(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
if store.Undismiss("missing") {
|
|
t.Error("expected false for missing finding")
|
|
}
|
|
})
|
|
|
|
t.Run("not dismissed", func(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.findings["f1"] = &Finding{ID: "f1", Severity: FindingSeverityWarning}
|
|
if store.Undismiss("f1") {
|
|
t.Error("expected false for non-dismissed finding")
|
|
}
|
|
})
|
|
|
|
t.Run("resolved stays inactive", func(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
resolvedAt := time.Now()
|
|
store.findings["f1"] = &Finding{
|
|
ID: "f1",
|
|
Severity: FindingSeverityWarning,
|
|
DismissedReason: "expected_behavior",
|
|
ResolvedAt: &resolvedAt,
|
|
}
|
|
|
|
if !store.Undismiss("f1") {
|
|
t.Fatal("expected Undismiss to succeed")
|
|
}
|
|
if store.activeCounts[FindingSeverityWarning] != 0 {
|
|
t.Errorf("expected active count to remain 0, got %d", store.activeCounts[FindingSeverityWarning])
|
|
}
|
|
})
|
|
|
|
t.Run("reactivates", func(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.findings["f1"] = &Finding{
|
|
ID: "f1",
|
|
Severity: FindingSeverityWarning,
|
|
DismissedReason: "expected_behavior",
|
|
}
|
|
store.activeCounts[FindingSeverityWarning] = 0
|
|
|
|
if !store.Undismiss("f1") {
|
|
t.Fatal("expected Undismiss to succeed")
|
|
}
|
|
if store.activeCounts[FindingSeverityWarning] != 1 {
|
|
t.Errorf("expected active count to increment to 1, got %d", store.activeCounts[FindingSeverityWarning])
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestFindingsStore_GetDismissedForContext_SnoozedAndOld(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
now := time.Now()
|
|
|
|
store.findings["old"] = &Finding{
|
|
ID: "old",
|
|
Title: "Old Finding",
|
|
ResourceName: "old",
|
|
DismissedReason: "not_an_issue",
|
|
LastSeenAt: now.Add(-31 * 24 * time.Hour),
|
|
}
|
|
store.findings["snoozed"] = &Finding{
|
|
ID: "snoozed",
|
|
Title: "Snoozed Finding",
|
|
ResourceName: "host-1",
|
|
SnoozedUntil: timePtr(now.Add(2 * time.Hour)),
|
|
LastSeenAt: now,
|
|
}
|
|
|
|
ctx := store.GetDismissedForContext()
|
|
if strings.Contains(ctx, "Old Finding") {
|
|
t.Error("old findings should be excluded from context")
|
|
}
|
|
if !strings.Contains(ctx, "Temporarily Snoozed") {
|
|
t.Error("expected snoozed section in context")
|
|
}
|
|
if !strings.Contains(ctx, "Snoozed Finding") {
|
|
t.Error("expected snoozed finding in context")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_GetDismissedForContext_SuppressedNoteAndDismissed(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
now := time.Now()
|
|
|
|
store.findings["suppressed"] = &Finding{
|
|
ID: "suppressed",
|
|
Title: "Suppressed Finding",
|
|
ResourceName: "host-1",
|
|
Suppressed: true,
|
|
DismissedReason: "not_an_issue",
|
|
UserNote: "ignore this",
|
|
LastSeenAt: now,
|
|
}
|
|
store.findings["dismissed"] = &Finding{
|
|
ID: "dismissed",
|
|
Title: "Dismissed Finding",
|
|
ResourceName: "host-2",
|
|
DismissedReason: "expected_behavior",
|
|
LastSeenAt: now,
|
|
}
|
|
store.findings["dismissed-note"] = &Finding{
|
|
ID: "dismissed-note",
|
|
Title: "Dismissed With Note",
|
|
ResourceName: "host-3",
|
|
DismissedReason: "will_fix_later",
|
|
UserNote: "accepted risk",
|
|
LastSeenAt: now,
|
|
}
|
|
|
|
ctx := store.GetDismissedForContext()
|
|
if !strings.Contains(ctx, "User note: ignore this") {
|
|
t.Error("expected suppressed user note in context")
|
|
}
|
|
if !strings.Contains(ctx, "Dismissed Finding") {
|
|
t.Error("expected dismissed finding in context")
|
|
}
|
|
if !strings.Contains(ctx, "User note: accepted risk") {
|
|
t.Error("expected dismissed user note in context")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_GetSuppressionRules_DismissedRule(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
now := time.Now()
|
|
|
|
store.findings["f1"] = &Finding{
|
|
ID: "f1",
|
|
ResourceID: "res-1",
|
|
ResourceName: "Res 1",
|
|
Category: FindingCategoryCapacity,
|
|
DismissedReason: "expected_behavior",
|
|
LastSeenAt: now,
|
|
}
|
|
|
|
rules := store.GetSuppressionRules()
|
|
if len(rules) != 1 {
|
|
t.Fatalf("expected 1 rule, got %d", len(rules))
|
|
}
|
|
rule := rules[0]
|
|
if rule.CreatedFrom != "dismissed" {
|
|
t.Errorf("expected created_from dismissed, got %s", rule.CreatedFrom)
|
|
}
|
|
if !rule.CreatedAt.Equal(now) {
|
|
t.Error("expected CreatedAt to use LastSeenAt when AcknowledgedAt is nil")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_DeleteSuppressionRule_Explicit(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
rule := store.AddSuppressionRule("res-1", "Res 1", FindingCategoryCapacity, "Manual")
|
|
|
|
if !store.DeleteSuppressionRule(rule.ID) {
|
|
t.Fatal("expected DeleteSuppressionRule to succeed")
|
|
}
|
|
if store.DeleteSuppressionRule(rule.ID) {
|
|
t.Error("expected DeleteSuppressionRule to return false after removal")
|
|
}
|
|
}
|
|
|
|
func TestFindingsStore_DeleteSuppressionRule_ReactivatesFinding(t *testing.T) {
|
|
store := NewFindingsStore()
|
|
store.findings["f1"] = &Finding{
|
|
ID: "f1",
|
|
Severity: FindingSeverityWarning,
|
|
DismissedReason: "not_an_issue",
|
|
Suppressed: true,
|
|
}
|
|
store.activeCounts[FindingSeverityWarning] = 0
|
|
|
|
if !store.DeleteSuppressionRule("finding_f1") {
|
|
t.Fatal("expected DeleteSuppressionRule to succeed")
|
|
}
|
|
if store.activeCounts[FindingSeverityWarning] != 1 {
|
|
t.Errorf("expected active count to increment, got %d", store.activeCounts[FindingSeverityWarning])
|
|
}
|
|
}
|