package alertcontrol import ( "context" "os" "sync" "testing" "time" "github.com/itworx/pulse/internal/database" ) func TestPostgreSQLControlsAreExpiringAuditedAndIdempotent(t *testing.T) { dsn := os.Getenv("PULSE_TEST_DATABASE_URL") if dsn == "" { t.Skip("PULSE_TEST_DATABASE_URL is not set") } ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() pool, err := database.NewPool(ctx, database.Config{URL: dsn, MaxConns: 8, MinConns: 1}) if err != nil { t.Fatal(err) } defer pool.Close() if err := database.Migrate(ctx, pool); err != nil { t.Fatal(err) } repo := Repository{Pool: pool} now := time.Now().UTC().Truncate(time.Microsecond) silenceID, maintenanceID, expiringID := NewID(), NewID(), NewID() t.Cleanup(func() { cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) defer cleanupCancel() _, _ = pool.Exec(cleanupCtx, `DELETE FROM alert_silences WHERE id = ANY($1::uuid[])`, []string{silenceID, expiringID}) _, _ = pool.Exec(cleanupCtx, `DELETE FROM maintenance_windows WHERE id = $1::uuid`, maintenanceID) }) silence, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: silenceID, Name: "planned deploy", Reason: "change window", Owner: "operator-1", Matchers: Matcher{Severities: []string{"critical"}}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}) if err != nil { t.Fatal(err) } if silence.State != StateActive || silence.Revision != 1 { t.Fatalf("unexpected silence: %#v", silence) } window, err := repo.CreateMaintenance(ctx, "operator-1", MaintenanceWindow{ID: maintenanceID, Name: "maintenance", Reason: "firmware", Selector: Matcher{EntityTypes: []string{"host"}}, StartsAt: now.Add(-time.Minute), EndsAt: now.Add(time.Hour)}) if err != nil { t.Fatal(err) } if window.State != StateActive { t.Fatalf("maintenance state = %s", window.State) } items, err := repo.ListSilences(ctx, 100, now) if err != nil || len(items) != 1 { t.Fatalf("list silences: %d, %v", len(items), err) } expiring, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: expiringID, Name: "short", Reason: "test expiry", Owner: "operator-1", Matchers: Matcher{}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(-time.Second)}) if err != nil { t.Fatal(err) } if expiring.State != StateExpired { t.Fatalf("expired control before job = %s", expiring.State) } first, err := repo.Expire(ctx, now) if err != nil { t.Fatal(err) } second, err := repo.Expire(ctx, now) if err != nil { t.Fatal(err) } if first.Silences != 1 || first.MaintenanceWindows != 0 || second != (ExpiryResult{}) { t.Fatalf("expiry not idempotent: first=%#v second=%#v", first, second) } concurrentID := NewID() defer func() { _, _ = pool.Exec(context.Background(), `DELETE FROM alert_silences WHERE id=$1::uuid`, concurrentID) }() if _, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: concurrentID, Name: "concurrent", Reason: "test", Owner: "operator-1", Matchers: Matcher{}, StartsAt: now.Add(-time.Minute), ExpiresAt: now.Add(-time.Second)}); err != nil { t.Fatal(err) } var wg sync.WaitGroup results := make(chan ExpiryResult, 2) errorsCh := make(chan error, 2) for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() result, err := repo.Expire(ctx, now.Add(time.Second)) if err != nil { errorsCh <- err return } results <- result }() } wg.Wait() close(results) close(errorsCh) total := 0 for result := range results { total += result.Silences + result.MaintenanceWindows } for err := range errorsCh { t.Fatal(err) } if total != 1 { t.Fatalf("concurrent expiry count = %d, want 1", total) } if _, err := repo.CreateSilence(ctx, "operator-1", Silence{ID: silenceID, Name: "duplicate", Reason: "duplicate", Owner: "operator-1", Matchers: Matcher{}, StartsAt: now, ExpiresAt: now.Add(time.Hour)}); err == nil { t.Fatal("duplicate silence should fail") } if _, err := repo.RevokeSilence(ctx, silenceID, "operator-1", silence.Revision, now); err != nil { t.Fatal(err) } if _, err := repo.RevokeSilence(ctx, silenceID, "operator-1", silence.Revision+1, now); err == nil { t.Fatal("repeated revoke should conflict") } }