Implements Spec 115 (Baseline Operability & Alert Integration). Key changes - Baseline compare: safe auto-close of stale baseline findings (gated on successful/complete compares) - Baseline alerts: `baseline_high_drift` + `baseline_compare_failed` with dedupe/cooldown semantics - Workspace settings: baseline severity mapping + minimum severity threshold + auto-close toggle - Baseline Compare UX: shared stats layer + landing/widget consistency Notes - Livewire v4 / Filament v5 compatible. - Destructive-like actions require confirmation (no new destructive actions added here). Tests - `vendor/bin/sail artisan test --compact tests/Feature/Baselines/ tests/Feature/Alerts/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #140
186 lines
6.6 KiB
PHP
186 lines
6.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Jobs\Alerts\EvaluateAlertsJob;
|
|
use App\Models\AlertDelivery;
|
|
use App\Models\AlertDestination;
|
|
use App\Models\AlertRule;
|
|
use App\Models\OperationRun;
|
|
use App\Models\Workspace;
|
|
use App\Services\Alerts\AlertDispatchService;
|
|
use App\Support\OperationRunOutcome;
|
|
use App\Support\OperationRunStatus;
|
|
use App\Support\OperationRunType;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
afterEach(function (): void {
|
|
CarbonImmutable::setTestNow();
|
|
});
|
|
|
|
/**
|
|
* @return array{0: AlertRule, 1: AlertDestination}
|
|
*/
|
|
function createBaselineCompareFailedRuleWithDestination(int $workspaceId, int $cooldownSeconds = 0): array
|
|
{
|
|
$destination = AlertDestination::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'is_enabled' => true,
|
|
]);
|
|
|
|
$rule = AlertRule::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'event_type' => 'baseline_compare_failed',
|
|
'minimum_severity' => 'low',
|
|
'is_enabled' => true,
|
|
'cooldown_seconds' => $cooldownSeconds,
|
|
]);
|
|
|
|
$rule->destinations()->attach($destination->getKey(), [
|
|
'workspace_id' => $workspaceId,
|
|
]);
|
|
|
|
return [$rule, $destination];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
function invokeBaselineCompareFailedEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
|
{
|
|
$job = new EvaluateAlertsJob($workspaceId);
|
|
$reflection = new ReflectionMethod($job, 'baselineCompareFailedEvents');
|
|
|
|
/** @var array<int, array<string, mixed>> $events */
|
|
$events = $reflection->invoke($job, $workspaceId, $windowStart);
|
|
|
|
return $events;
|
|
}
|
|
|
|
it('produces baseline compare failed events for failed and partially succeeded baseline compare runs', function (): void {
|
|
$now = CarbonImmutable::parse('2026-02-28T12:00:00Z');
|
|
CarbonImmutable::setTestNow($now);
|
|
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
|
$windowStart = $now->subHour();
|
|
|
|
$failedRun = OperationRun::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'type' => OperationRunType::BaselineCompare->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'completed_at' => $now->subMinutes(10),
|
|
'failure_summary' => [
|
|
['message' => 'The baseline compare failed.'],
|
|
],
|
|
]);
|
|
|
|
$partialRun = OperationRun::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'type' => OperationRunType::BaselineCompare->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
|
'completed_at' => $now->subMinutes(5),
|
|
'failure_summary' => [
|
|
['message' => 'The baseline compare partially succeeded.'],
|
|
],
|
|
]);
|
|
|
|
OperationRun::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'type' => OperationRunType::BaselineCompare->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
'completed_at' => $now->subMinutes(5),
|
|
]);
|
|
|
|
OperationRun::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'type' => 'drift_generate_findings',
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'completed_at' => $now->subMinutes(5),
|
|
]);
|
|
|
|
$events = invokeBaselineCompareFailedEvents($workspaceId, $windowStart);
|
|
|
|
expect($events)->toHaveCount(2);
|
|
|
|
$eventsByRunId = collect($events)->keyBy(static fn (array $event): int => (int) $event['metadata']['operation_run_id']);
|
|
|
|
expect($eventsByRunId[$failedRun->getKey()])
|
|
->toMatchArray([
|
|
'event_type' => 'baseline_compare_failed',
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'severity' => 'high',
|
|
'fingerprint_key' => 'operation_run:'.$failedRun->getKey(),
|
|
'metadata' => [
|
|
'operation_run_id' => (int) $failedRun->getKey(),
|
|
],
|
|
]);
|
|
|
|
expect($eventsByRunId[$partialRun->getKey()])
|
|
->toMatchArray([
|
|
'event_type' => 'baseline_compare_failed',
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'severity' => 'high',
|
|
'fingerprint_key' => 'operation_run:'.$partialRun->getKey(),
|
|
'metadata' => [
|
|
'operation_run_id' => (int) $partialRun->getKey(),
|
|
],
|
|
]);
|
|
});
|
|
|
|
it('keeps baseline compare failed events compatible with dispatcher cooldown dedupe', function (): void {
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$workspaceId = (int) session()->get(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY);
|
|
[$rule, $destination] = createBaselineCompareFailedRuleWithDestination($workspaceId, cooldownSeconds: 3600);
|
|
|
|
$run = OperationRun::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'type' => OperationRunType::BaselineCompare->value,
|
|
'status' => OperationRunStatus::Completed->value,
|
|
'outcome' => OperationRunOutcome::Failed->value,
|
|
'completed_at' => now(),
|
|
]);
|
|
|
|
$event = [
|
|
'event_type' => 'baseline_compare_failed',
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'severity' => 'high',
|
|
'fingerprint_key' => 'operation_run:'.$run->getKey(),
|
|
'title' => 'Baseline compare failed',
|
|
'body' => 'The baseline compare failed.',
|
|
'metadata' => [
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
],
|
|
];
|
|
|
|
$workspace = Workspace::query()->findOrFail($workspaceId);
|
|
$dispatchService = app(AlertDispatchService::class);
|
|
|
|
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
|
|
expect($dispatchService->dispatchEvent($workspace, $event))->toBe(1);
|
|
|
|
$deliveries = AlertDelivery::query()
|
|
->where('workspace_id', $workspaceId)
|
|
->where('alert_rule_id', (int) $rule->getKey())
|
|
->where('alert_destination_id', (int) $destination->getKey())
|
|
->where('event_type', 'baseline_compare_failed')
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
expect($deliveries)->toHaveCount(2);
|
|
expect($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED);
|
|
expect($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
|
});
|