## Summary - implement Spec 224 findings notifications and escalation v1 on top of the existing alerts and Filament database notification infrastructure - add finding assignment, reopen, due soon, and overdue event handling with direct recipient routing, dedupe, and optional external alert fan-out - extend alert rule and alert delivery surfaces plus add the Spec 224 planning bundle and candidate-list promotion cleanup ## Validation - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php` - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Alerts/FindingsAlertRuleIntegrationTest.php tests/Feature/Alerts/SlaDueAlertTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php` ## Filament / Platform Notes - Livewire v4.0+ compliance is preserved - provider registration remains unchanged in `apps/platform/bootstrap/providers.php` - no globally searchable resource behavior changed in this feature - no new destructive action was introduced - asset strategy is unchanged and the existing `cd apps/platform && php artisan filament:assets` deploy step remains sufficient ## Manual Smoke Note - integrated-browser smoke testing confirmed the new alert rule event options, notification drawer entries, alert delivery history row, and tenant finding detail route on the active Sail host - local notification deep links currently resolve from `APP_URL`, so a local `localhost` vs `127.0.0.1:8081` host mismatch can break the browser session if the app is opened on a different host/port combination Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #261
235 lines
7.7 KiB
PHP
235 lines
7.7 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AlertRule;
|
|
use App\Models\Finding;
|
|
use App\Models\Tenant;
|
|
use App\Support\Workspaces\WorkspaceContext;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
afterEach(function (): void {
|
|
CarbonImmutable::setTestNow();
|
|
});
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): array
|
|
{
|
|
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId);
|
|
$reflection = new ReflectionMethod($job, 'slaDueEvents');
|
|
|
|
/** @var array<int, array<string, mixed>> $events */
|
|
$events = $reflection->invoke($job, $workspaceId, $windowStart);
|
|
|
|
return $events;
|
|
}
|
|
|
|
it('produces one sla due event per tenant and summarizes current overdue open findings', function (): void {
|
|
$now = CarbonImmutable::parse('2026-02-24T12:00:00Z');
|
|
CarbonImmutable::setTestNow($now);
|
|
|
|
[$user, $tenantA] = createUserWithTenant(role: 'owner');
|
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
|
$tenantB = Tenant::factory()->create(['workspace_id' => $workspaceId]);
|
|
$tenantC = Tenant::factory()->create(['workspace_id' => $workspaceId]);
|
|
|
|
$windowStart = $now->subHour();
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantA->getKey(),
|
|
'status' => Finding::STATUS_IN_PROGRESS,
|
|
'severity' => Finding::SEVERITY_CRITICAL,
|
|
'due_at' => $now->subMinutes(10),
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantA->getKey(),
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'due_at' => $now->subDays(1),
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantA->getKey(),
|
|
'status' => Finding::STATUS_NEW,
|
|
'severity' => Finding::SEVERITY_MEDIUM,
|
|
'due_at' => $windowStart,
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantA->getKey(),
|
|
'status' => Finding::STATUS_RESOLVED,
|
|
'severity' => Finding::SEVERITY_LOW,
|
|
'due_at' => $now->subMinutes(5),
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantB->getKey(),
|
|
'status' => Finding::STATUS_REOPENED,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'due_at' => $now->subDays(2),
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenantC->getKey(),
|
|
'status' => Finding::STATUS_NEW,
|
|
'severity' => Finding::SEVERITY_LOW,
|
|
'due_at' => $now->subMinutes(20),
|
|
]);
|
|
|
|
$events = invokeSlaDueEvents($workspaceId, $windowStart);
|
|
|
|
expect($events)->toHaveCount(2);
|
|
|
|
$eventsByTenant = collect($events)->keyBy(static fn (array $event): int => (int) $event['tenant_id']);
|
|
|
|
expect($eventsByTenant->keys()->all())
|
|
->toEqualCanonicalizing([(int) $tenantA->getKey(), (int) $tenantC->getKey()]);
|
|
|
|
$tenantAEvent = $eventsByTenant->get((int) $tenantA->getKey());
|
|
|
|
expect($tenantAEvent)
|
|
->not->toBeNull()
|
|
->and($tenantAEvent['event_type'])->toBe(AlertRule::EVENT_SLA_DUE)
|
|
->and($tenantAEvent['severity'])->toBe(Finding::SEVERITY_CRITICAL)
|
|
->and($tenantAEvent['metadata'])->toMatchArray([
|
|
'overdue_total' => 3,
|
|
'overdue_by_severity' => [
|
|
'critical' => 1,
|
|
'high' => 1,
|
|
'medium' => 1,
|
|
'low' => 0,
|
|
],
|
|
])
|
|
->and($tenantAEvent['metadata'])->not->toHaveKey('finding_ids');
|
|
|
|
$tenantCEvent = $eventsByTenant->get((int) $tenantC->getKey());
|
|
|
|
expect($tenantCEvent)
|
|
->not->toBeNull()
|
|
->and($tenantCEvent['severity'])->toBe(Finding::SEVERITY_LOW)
|
|
->and($tenantCEvent['metadata'])->toMatchArray([
|
|
'overdue_total' => 1,
|
|
'overdue_by_severity' => [
|
|
'critical' => 0,
|
|
'high' => 0,
|
|
'medium' => 0,
|
|
'low' => 1,
|
|
],
|
|
]);
|
|
});
|
|
|
|
it('gates sla due events to newly overdue open findings after window start', function (): void {
|
|
$now = CarbonImmutable::parse('2026-02-24T12:00:00Z');
|
|
CarbonImmutable::setTestNow($now);
|
|
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
|
|
|
$windowStart = $now->subHour();
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'status' => Finding::STATUS_NEW,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'due_at' => $now->subDays(1),
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'status' => Finding::STATUS_NEW,
|
|
'severity' => Finding::SEVERITY_MEDIUM,
|
|
'due_at' => $windowStart,
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'status' => Finding::STATUS_CLOSED,
|
|
'severity' => Finding::SEVERITY_CRITICAL,
|
|
'due_at' => $now->subMinutes(5),
|
|
]);
|
|
|
|
expect(invokeSlaDueEvents($workspaceId, $windowStart))->toBe([]);
|
|
});
|
|
|
|
it('uses a stable fingerprint per tenant and alert window', function (): void {
|
|
$now = CarbonImmutable::parse('2026-02-24T12:00:00Z');
|
|
CarbonImmutable::setTestNow($now);
|
|
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'status' => Finding::STATUS_NEW,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'due_at' => $now->subMinute(),
|
|
]);
|
|
|
|
$windowA = $now->subMinutes(5);
|
|
$windowB = $now->subMinutes(2);
|
|
|
|
$first = invokeSlaDueEvents($workspaceId, $windowA);
|
|
$second = invokeSlaDueEvents($workspaceId, $windowA);
|
|
$third = invokeSlaDueEvents($workspaceId, $windowB);
|
|
|
|
expect($first)->toHaveCount(1)
|
|
->and($second)->toHaveCount(1)
|
|
->and($third)->toHaveCount(1)
|
|
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
|
|
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
|
|
});
|
|
|
|
it('keeps aggregate sla due alerts separate from finding-level due soon reminders', function (): void {
|
|
$now = CarbonImmutable::parse('2026-04-22T12:00:00Z');
|
|
CarbonImmutable::setTestNow($now);
|
|
|
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'status' => Finding::STATUS_TRIAGED,
|
|
'severity' => Finding::SEVERITY_HIGH,
|
|
'due_at' => $now->subHour(),
|
|
]);
|
|
|
|
Finding::factory()->create([
|
|
'workspace_id' => $workspaceId,
|
|
'tenant_id' => $tenant->getKey(),
|
|
'status' => Finding::STATUS_IN_PROGRESS,
|
|
'severity' => Finding::SEVERITY_CRITICAL,
|
|
'due_at' => $now->addHours(6),
|
|
]);
|
|
|
|
$events = invokeSlaDueEvents($workspaceId, $now->subDay());
|
|
|
|
expect($events)->toHaveCount(1)
|
|
->and($events[0]['event_type'])->toBe(AlertRule::EVENT_SLA_DUE)
|
|
->and($events[0]['metadata'])->toMatchArray([
|
|
'overdue_total' => 1,
|
|
'overdue_by_severity' => [
|
|
'critical' => 0,
|
|
'high' => 1,
|
|
'medium' => 0,
|
|
'low' => 0,
|
|
],
|
|
]);
|
|
});
|