TenantAtlas/apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php
ahmido e15d80cca5
Some checks failed
Main Confidence / confidence (push) Failing after 48s
Heavy Governance Lane / heavy-governance (push) Has been skipped
Browser Lane / browser (push) Has been skipped
feat: implement findings notifications escalation (#261)
## 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
2026-04-22 00:54:38 +00:00

200 lines
8.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AlertRule;
use App\Models\Finding;
use App\Models\TenantMembership;
use App\Models\User;
use App\Notifications\Findings\FindingEventNotification;
use App\Services\Auth\CapabilityResolver;
use App\Services\Findings\FindingNotificationService;
use App\Services\Findings\FindingWorkflowService;
use Carbon\CarbonImmutable;
afterEach(function (): void {
CarbonImmutable::setTestNow();
});
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
{
return $user->notifications()
->where('type', FindingEventNotification::class)
->orderBy('id')
->get();
}
it('uses the documented recipient precedence for assignment reopen due soon and overdue', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$service = app(FindingNotificationService::class);
$assignee = User::factory()->create(['name' => 'Assignee']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'due_at' => now()->addHours(6),
]);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect(dispatchedFindingNotificationsFor($assignee)
->pluck('data.finding_event.event_type')
->all())
->toContain(AlertRule::EVENT_FINDINGS_ASSIGNED, AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
expect(dispatchedFindingNotificationsFor($owner)
->pluck('data.finding_event.event_type')
->all())
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
$fallbackFinding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => null,
'due_at' => now()->addHours(2),
]);
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_REOPENED);
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_DUE_SOON);
$ownerEventTypes = dispatchedFindingNotificationsFor($owner)
->pluck('data.finding_event.event_type')
->all();
expect($ownerEventTypes)->toContain(AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
});
it('suppresses direct delivery when the preferred recipient loses tenant access', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Removed Assignee']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_REOPENED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('user_id', (int) $assignee->getKey())
->delete();
app(CapabilityResolver::class)->clearCache();
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('does not broaden delivery to the owner when the assignee is present but no longer entitled', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Current Assignee']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
'due_at' => now()->addHours(3),
]);
$resolver = \Mockery::mock(CapabilityResolver::class);
$resolver->shouldReceive('isMember')->andReturnTrue();
$resolver->shouldReceive('can')
->andReturnUsing(function (User $user): bool {
return $user->name !== 'Current Assignee';
});
app()->instance(CapabilityResolver::class, $resolver);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('suppresses owner-only assignment edits and assignee clears from creating direct notifications', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_TRIAGED,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $assignee->getKey(),
]);
$workflow = app(FindingWorkflowService::class);
$workflow->assign(
finding: $finding,
tenant: $tenant,
actor: $owner,
assigneeUserId: (int) $assignee->getKey(),
ownerUserId: (int) $replacementOwner->getKey(),
);
$workflow->assign(
finding: $finding->fresh(),
tenant: $tenant,
actor: $owner,
assigneeUserId: null,
ownerUserId: (int) $replacementOwner->getKey(),
);
expect(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0)
->and(dispatchedFindingNotificationsFor($replacementOwner))->toHaveCount(0);
});
it('suppresses due notifications for terminal findings', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->closed()->create([
'workspace_id' => (int) $tenant->workspace_id,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $owner->getKey(),
'due_at' => now()->subHour(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect($result['direct_delivery_status'])->toBe('suppressed')
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
});
it('sends one direct notification when owner and assignee are the same entitled user', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$finding = Finding::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'status' => Finding::STATUS_IN_PROGRESS,
'owner_user_id' => (int) $owner->getKey(),
'assignee_user_id' => (int) $owner->getKey(),
'due_at' => now()->subHour(),
]);
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
expect($result['direct_delivery_status'])->toBe('sent')
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1)
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner');
});