toMatchArray([ AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned', AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened', AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon', AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue', ]); }); it('delivers a direct finding notification without requiring a matching alert rule', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $assignee = User::factory()->create(['name' => 'Assigned Operator']); 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(), ]); $result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED); expect($result['direct_delivery_status'])->toBe('sent') ->and($result['external_delivery_count'])->toBe(0) ->and($assignee->notifications()->where('type', FindingEventNotification::class)->count())->toBe(1) ->and(AlertDelivery::query()->count())->toBe(0); }); it('fans out matching external copies through the existing alert delivery pipeline', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $workspaceId = (int) $tenant->workspace_id; $destination = AlertDestination::factory()->create([ 'workspace_id' => $workspaceId, 'is_enabled' => true, ]); $rule = AlertRule::factory()->create([ 'workspace_id' => $workspaceId, 'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE, 'minimum_severity' => Finding::SEVERITY_MEDIUM, 'is_enabled' => true, 'cooldown_seconds' => 0, ]); $rule->destinations()->attach($destination->getKey(), [ 'workspace_id' => $workspaceId, ]); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => $workspaceId, 'status' => Finding::STATUS_IN_PROGRESS, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => null, 'severity' => Finding::SEVERITY_HIGH, 'due_at' => now()->subHour(), ]); $result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE); $delivery = AlertDelivery::query()->latest('id')->first(); expect($result['direct_delivery_status'])->toBe('sent') ->and($result['external_delivery_count'])->toBe(1) ->and($delivery)->not->toBeNull() ->and($delivery?->event_type)->toBe(AlertRule::EVENT_FINDINGS_OVERDUE) ->and(data_get($delivery?->payload, 'title'))->toBe('Finding overdue'); }); it('inherits minimum severity tenant scoping and cooldown suppression for finding alert copies', function (): void { [$ownerA, $tenantA] = createUserWithTenant(role: 'owner'); $workspaceId = (int) $tenantA->workspace_id; $tenantB = Tenant::factory()->create([ 'workspace_id' => $workspaceId, ]); [$ownerB] = createUserWithTenant(tenant: $tenantB, role: 'owner'); $destination = AlertDestination::factory()->create([ 'workspace_id' => $workspaceId, 'is_enabled' => true, ]); $rule = AlertRule::factory()->create([ 'workspace_id' => $workspaceId, 'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE, 'minimum_severity' => Finding::SEVERITY_HIGH, 'tenant_scope_mode' => AlertRule::TENANT_SCOPE_ALLOWLIST, 'tenant_allowlist' => [(int) $tenantA->getKey()], 'is_enabled' => true, 'cooldown_seconds' => 3600, ]); $rule->destinations()->attach($destination->getKey(), [ 'workspace_id' => $workspaceId, ]); $mediumFinding = Finding::factory()->for($tenantA)->create([ 'workspace_id' => $workspaceId, 'status' => Finding::STATUS_IN_PROGRESS, 'owner_user_id' => (int) $ownerA->getKey(), 'severity' => Finding::SEVERITY_MEDIUM, 'due_at' => now()->subHour(), ]); $scopedOutFinding = Finding::factory()->for($tenantB)->create([ 'workspace_id' => $workspaceId, 'status' => Finding::STATUS_IN_PROGRESS, 'owner_user_id' => (int) $ownerB->getKey(), 'severity' => Finding::SEVERITY_CRITICAL, 'due_at' => now()->subHour(), ]); $trackedFinding = Finding::factory()->for($tenantA)->create([ 'workspace_id' => $workspaceId, 'status' => Finding::STATUS_IN_PROGRESS, 'owner_user_id' => (int) $ownerA->getKey(), 'severity' => Finding::SEVERITY_HIGH, 'due_at' => now()->subHour(), ]); expect(app(FindingNotificationService::class)->dispatch($mediumFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0) ->and(app(FindingNotificationService::class)->dispatch($scopedOutFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0); app(FindingNotificationService::class)->dispatch($trackedFinding, AlertRule::EVENT_FINDINGS_OVERDUE); app(FindingNotificationService::class)->dispatch($trackedFinding->fresh(), AlertRule::EVENT_FINDINGS_OVERDUE); $deliveries = AlertDelivery::query() ->where('workspace_id', $workspaceId) ->where('event_type', AlertRule::EVENT_FINDINGS_OVERDUE) ->orderBy('id') ->get(); expect($deliveries)->toHaveCount(2) ->and($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED) ->and($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED); }); it('inherits quiet hours deferral for finding alert copies', function (): void { [$owner, $tenant] = createUserWithTenant(role: 'owner'); $workspaceId = (int) $tenant->workspace_id; $destination = AlertDestination::factory()->create([ 'workspace_id' => $workspaceId, 'is_enabled' => true, ]); $rule = AlertRule::factory()->create([ 'workspace_id' => $workspaceId, 'event_type' => AlertRule::EVENT_FINDINGS_ASSIGNED, 'minimum_severity' => Finding::SEVERITY_LOW, 'is_enabled' => true, 'cooldown_seconds' => 0, 'quiet_hours_enabled' => true, 'quiet_hours_start' => '00:00', 'quiet_hours_end' => '23:59', 'quiet_hours_timezone' => 'UTC', ]); $rule->destinations()->attach($destination->getKey(), [ 'workspace_id' => $workspaceId, ]); $finding = Finding::factory()->for($tenant)->create([ 'workspace_id' => $workspaceId, 'status' => Finding::STATUS_TRIAGED, 'owner_user_id' => (int) $owner->getKey(), 'assignee_user_id' => (int) $owner->getKey(), 'severity' => Finding::SEVERITY_LOW, ]); $result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED); $delivery = AlertDelivery::query()->latest('id')->first(); expect($result['external_delivery_count'])->toBe(1) ->and($delivery)->not->toBeNull() ->and($delivery?->status)->toBe(AlertDelivery::STATUS_DEFERRED); }); it('renders finding event labels and filters in the existing alert deliveries viewer', function (): void { $tenant = Tenant::factory()->create(); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); $workspaceId = (int) $tenant->workspace_id; $rule = AlertRule::factory()->create([ 'workspace_id' => $workspaceId, 'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE, ]); $destination = AlertDestination::factory()->create([ 'workspace_id' => $workspaceId, 'is_enabled' => true, ]); $delivery = AlertDelivery::factory()->create([ 'workspace_id' => $workspaceId, 'tenant_id' => (int) $tenant->getKey(), 'alert_rule_id' => (int) $rule->getKey(), 'alert_destination_id' => (int) $destination->getKey(), 'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE, 'payload' => [ 'title' => 'Finding overdue', 'body' => 'A finding is overdue and needs follow-up.', ], ]); $this->actingAs($user); Filament::setTenant($tenant, true); Livewire::test(ListAlertDeliveries::class) ->filterTable('event_type', AlertRule::EVENT_FINDINGS_OVERDUE) ->assertCanSeeTableRecords([$delivery]) ->assertSee('Finding overdue'); expect(AlertRuleResource::eventTypeLabel(AlertRule::EVENT_FINDINGS_OVERDUE))->toBe('Finding overdue'); }); it('preserves alerts read and mutation boundaries for the existing admin surfaces', function (): void { $workspace = Workspace::factory()->create(); $viewer = User::factory()->create(); WorkspaceMembership::factory()->create([ 'workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $viewer->getKey(), 'role' => 'readonly', ]); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey()); $this->actingAs($viewer) ->get(AlertRuleResource::getUrl(panel: 'admin')) ->assertOk(); $this->actingAs($viewer) ->get(AlertDeliveryResource::getUrl(panel: 'admin')) ->assertOk(); $this->actingAs($viewer) ->get(AlertRuleResource::getUrl('create', panel: 'admin')) ->assertForbidden(); $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); $resolver->shouldReceive('isMember')->andReturnTrue(); $resolver->shouldReceive('can')->andReturnFalse(); app()->instance(WorkspaceCapabilityResolver::class, $resolver); $this->actingAs($viewer) ->get(AlertRuleResource::getUrl(panel: 'admin')) ->assertForbidden(); $this->actingAs($viewer) ->get(AlertDeliveryResource::getUrl(panel: 'admin')) ->assertForbidden(); $outsider = User::factory()->create(); app()->forgetInstance(WorkspaceCapabilityResolver::class); $this->actingAs($outsider) ->get(AlertRuleResource::getUrl(panel: 'admin')) ->assertNotFound(); });