diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index bcf1b99..c83cbd3 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -29,6 +29,7 @@ ## Active Technologies - PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub) - PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal) - PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` (095-graph-contracts-registry-completeness) +- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications (100-alert-target-test-actions) - PHP 8.4.15 (feat/005-bulk-operations) @@ -48,8 +49,7 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 100-alert-target-test-actions: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Laravel Queue, Laravel Notifications - 095-graph-contracts-registry-completeness: Added PHP 8.4.x + Laravel 12, Filament v5, Livewire v4, Microsoft Graph integration via `GraphClientInterface` -- 090-action-surface-contract-compliance: Added PHP 8.4.15 -- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4 diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index abd426b..e17ec6f 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -70,6 +70,7 @@ ### Tenant Isolation is Non-negotiable - Tenant-owned tables MUST include workspace_id and tenant_id as NOT NULL. - Workspace-owned tables MUST include workspace_id and MUST NOT include tenant_id. - Exception: OperationRun MAY have tenant_id nullable to support canonical workspace-context monitoring views; however, revealing any tenant-bound runs still MUST enforce entitlement checks to the referenced tenant scope. +- Exception: AlertDelivery MAY have tenant_id nullable for workspace-scoped, non-tenant-operational artifacts (e.g., `event_type=alerts.test`). Tenant-bound delivery records still MUST enforce tenant entitlement checks, and tenantless delivery rows MUST NOT contain tenant-specific data. ### RBAC & UI Enforcement Standards (RBAC-UX) diff --git a/app/Filament/Resources/AlertDeliveryResource.php b/app/Filament/Resources/AlertDeliveryResource.php index fd7a9c2..05f1746 100644 --- a/app/Filament/Resources/AlertDeliveryResource.php +++ b/app/Filament/Resources/AlertDeliveryResource.php @@ -7,8 +7,11 @@ use App\Filament\Clusters\Monitoring\AlertsCluster; use App\Filament\Resources\AlertDeliveryResource\Pages; use App\Models\AlertDelivery; +use App\Models\AlertDestination; use App\Models\Tenant; use App\Models\User; +use App\Support\Badges\BadgeDomain; +use App\Support\Badges\BadgeRenderer; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; @@ -114,7 +117,10 @@ public static function getEloquentQuery(): Builder ) ->when( $user instanceof User, - fn (Builder $query): Builder => $query->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')), + fn (Builder $query): Builder => $query->where(function (Builder $q) use ($user): void { + $q->whereIn('tenant_id', $user->tenantMemberships()->select('tenant_id')) + ->orWhereNull('tenant_id'); + }), ) ->when( Filament::getTenant() instanceof Tenant, @@ -136,8 +142,9 @@ public static function infolist(Schema $schema): Schema ->schema([ TextEntry::make('status') ->badge() - ->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state)) - ->color(fn (?string $state): string => self::statusColor((string) $state)), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus)) + ->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)), TextEntry::make('event_type') ->label('Event') ->badge() @@ -214,8 +221,9 @@ public static function table(Table $table): Table ->placeholder('—'), TextColumn::make('status') ->badge() - ->formatStateUsing(fn (?string $state): string => self::statusLabel((string) $state)) - ->color(fn (?string $state): string => self::statusColor((string) $state)), + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDeliveryStatus)) + ->color(BadgeRenderer::color(BadgeDomain::AlertDeliveryStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::AlertDeliveryStatus)), TextColumn::make('rule.name') ->label('Rule') ->placeholder('—'), @@ -235,6 +243,29 @@ public static function table(Table $table): Table AlertDelivery::STATUS_SUPPRESSED => 'Suppressed', AlertDelivery::STATUS_CANCELED => 'Canceled', ]), + SelectFilter::make('event_type') + ->label('Event type') + ->options(function (): array { + $options = AlertRuleResource::eventTypeOptions(); + $options[AlertDelivery::EVENT_TYPE_TEST] = 'Test'; + + return $options; + }), + SelectFilter::make('alert_destination_id') + ->label('Destination') + ->options(function (): array { + $workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request()); + + if (! is_int($workspaceId)) { + return []; + } + + return AlertDestination::query() + ->where('workspace_id', $workspaceId) + ->orderBy('name') + ->pluck('name', 'id') + ->all(); + }), ]) ->actions([ ViewAction::make()->label('View'), @@ -249,30 +280,4 @@ public static function getPages(): array 'view' => Pages\ViewAlertDelivery::route('/{record}'), ]; } - - private static function statusLabel(string $status): string - { - return match ($status) { - AlertDelivery::STATUS_QUEUED => 'Queued', - AlertDelivery::STATUS_DEFERRED => 'Deferred', - AlertDelivery::STATUS_SENT => 'Sent', - AlertDelivery::STATUS_FAILED => 'Failed', - AlertDelivery::STATUS_SUPPRESSED => 'Suppressed', - AlertDelivery::STATUS_CANCELED => 'Canceled', - default => ucfirst($status), - }; - } - - private static function statusColor(string $status): string - { - return match ($status) { - AlertDelivery::STATUS_QUEUED => 'gray', - AlertDelivery::STATUS_DEFERRED => 'warning', - AlertDelivery::STATUS_SENT => 'success', - AlertDelivery::STATUS_FAILED => 'danger', - AlertDelivery::STATUS_SUPPRESSED => 'info', - AlertDelivery::STATUS_CANCELED => 'gray', - default => 'gray', - }; - } } diff --git a/app/Filament/Resources/AlertDestinationResource.php b/app/Filament/Resources/AlertDestinationResource.php index 57e43da..fbea303 100644 --- a/app/Filament/Resources/AlertDestinationResource.php +++ b/app/Filament/Resources/AlertDestinationResource.php @@ -173,7 +173,7 @@ public static function table(Table $table): Table ->defaultSort('name') ->recordUrl(fn (AlertDestination $record): ?string => static::canEdit($record) ? static::getUrl('edit', ['record' => $record]) - : null) + : static::getUrl('view', ['record' => $record])) ->columns([ TextColumn::make('name') ->searchable(), @@ -266,6 +266,7 @@ public static function getPages(): array return [ 'index' => Pages\ListAlertDestinations::route('/'), 'create' => Pages\CreateAlertDestination::route('/create'), + 'view' => Pages\ViewAlertDestination::route('/{record}'), 'edit' => Pages\EditAlertDestination::route('/{record}/edit'), ]; } diff --git a/app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php b/app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php index 2bbd2c1..6c3c718 100644 --- a/app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php +++ b/app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php @@ -4,9 +4,15 @@ namespace App\Filament\Resources\AlertDestinationResource\Pages; +use App\Filament\Resources\AlertDeliveryResource; use App\Filament\Resources\AlertDestinationResource; use App\Models\AlertDestination; +use App\Models\User; +use App\Services\Alerts\AlertDestinationLastTestResolver; +use App\Services\Alerts\AlertDestinationTestMessageService; +use App\Support\Alerts\AlertDestinationLastTestStatus; use App\Support\Audit\AuditActionId; +use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; @@ -14,6 +20,80 @@ class EditAlertDestination extends EditRecord { protected static string $resource = AlertDestinationResource::class; + private ?AlertDestinationLastTestStatus $lastTestStatus = null; + + public function mount(int|string $record): void + { + parent::mount($record); + $this->resolveLastTestStatus(); + } + + protected function getHeaderActions(): array + { + $user = auth()->user(); + $record = $this->record; + $canManage = $user instanceof User + && $record instanceof AlertDestination + && $user->can('update', $record); + + return [ + Action::make('send_test_message') + ->label('Send test message') + ->icon('heroicon-o-paper-airplane') + ->requiresConfirmation() + ->modalHeading('Send test message') + ->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.') + ->modalSubmitActionLabel('Send') + ->visible(fn (): bool => $record instanceof AlertDestination) + ->disabled(fn (): bool => ! $canManage) + ->action(function () use ($record): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $record instanceof AlertDestination) { + return; + } + + $service = app(AlertDestinationTestMessageService::class); + $result = $service->sendTest($record, $user); + + if ($result['success']) { + Notification::make() + ->title($result['message']) + ->success() + ->send(); + } else { + Notification::make() + ->title($result['message']) + ->warning() + ->send(); + } + + $this->resolveLastTestStatus(); + }), + + Action::make('view_last_delivery') + ->label('View last delivery') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (): ?string => $this->buildDeepLinkUrl()) + ->openUrlInNewTab() + ->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null), + ]; + } + + public function getSubheading(): ?string + { + if ($this->lastTestStatus === null) { + return null; + } + + $label = ucfirst($this->lastTestStatus->status->value); + $timestamp = $this->lastTestStatus->timestamp?->diffForHumans(); + + return $timestamp !== null + ? "Last test: {$label} ({$timestamp})" + : "Last test: {$label}"; + } + protected function mutateFormDataBeforeSave(array $data): array { $record = $this->record; @@ -46,4 +126,34 @@ protected function afterSave(): void ->success() ->send(); } + + private function resolveLastTestStatus(): void + { + $record = $this->record; + + if (! $record instanceof AlertDestination) { + return; + } + + $this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record); + } + + private function buildDeepLinkUrl(): ?string + { + $record = $this->record; + + if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) { + return null; + } + + $baseUrl = AlertDeliveryResource::getUrl('index'); + $params = http_build_query([ + 'filters' => [ + 'event_type' => ['value' => 'alerts.test'], + 'alert_destination_id' => ['value' => (string) $record->getKey()], + ], + ]); + + return "{$baseUrl}?{$params}"; + } } diff --git a/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php b/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php new file mode 100644 index 0000000..0a821f5 --- /dev/null +++ b/app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php @@ -0,0 +1,154 @@ +resolveLastTestStatus(); + } + + protected function getHeaderActions(): array + { + $user = auth()->user(); + $record = $this->record; + $canManage = $user instanceof User + && $record instanceof AlertDestination + && $user->can('update', $record); + + return [ + Action::make('send_test_message') + ->label('Send test message') + ->icon('heroicon-o-paper-airplane') + ->requiresConfirmation() + ->modalHeading('Send test message') + ->modalDescription('A test delivery will be queued for this destination. This verifies the delivery pipeline is working.') + ->modalSubmitActionLabel('Send') + ->visible(fn (): bool => $record instanceof AlertDestination) + ->disabled(fn (): bool => ! $canManage) + ->action(function () use ($record): void { + $user = auth()->user(); + + if (! $user instanceof User || ! $record instanceof AlertDestination) { + return; + } + + $service = app(AlertDestinationTestMessageService::class); + $result = $service->sendTest($record, $user); + + if ($result['success']) { + Notification::make() + ->title($result['message']) + ->success() + ->send(); + } else { + Notification::make() + ->title($result['message']) + ->warning() + ->send(); + } + + $this->resolveLastTestStatus(); + }), + + Action::make('view_last_delivery') + ->label('View last delivery') + ->icon('heroicon-o-arrow-top-right-on-square') + ->url(fn (): ?string => $this->buildDeepLinkUrl()) + ->openUrlInNewTab() + ->visible(fn (): bool => $this->lastTestStatus?->deliveryId !== null), + ]; + } + + public function infolist(Schema $schema): Schema + { + $lastTest = $this->lastTestStatus ?? AlertDestinationLastTestStatus::never(); + + return $schema + ->schema([ + Section::make('Last test') + ->schema([ + TextEntry::make('last_test_status') + ->label('Status') + ->badge() + ->state($lastTest->status->value) + ->formatStateUsing(BadgeRenderer::label(BadgeDomain::AlertDestinationLastTestStatus)) + ->color(BadgeRenderer::color(BadgeDomain::AlertDestinationLastTestStatus)) + ->icon(BadgeRenderer::icon(BadgeDomain::AlertDestinationLastTestStatus)), + TextEntry::make('last_test_timestamp') + ->label('Timestamp') + ->state($lastTest->timestamp?->toDateTimeString()) + ->placeholder('—'), + ]) + ->columns(2), + Section::make('Details') + ->schema([ + TextEntry::make('name'), + TextEntry::make('type') + ->badge() + ->formatStateUsing(fn (?string $state): string => AlertDestinationResource::typeLabel((string) $state)), + TextEntry::make('is_enabled') + ->label('Enabled') + ->badge() + ->formatStateUsing(fn (bool $state): string => $state ? 'Yes' : 'No') + ->color(fn (bool $state): string => $state ? 'success' : 'gray'), + TextEntry::make('created_at') + ->dateTime(), + TextEntry::make('updated_at') + ->dateTime(), + ]) + ->columns(2), + ]); + } + + private function resolveLastTestStatus(): void + { + $record = $this->record; + + if (! $record instanceof AlertDestination) { + return; + } + + $this->lastTestStatus = app(AlertDestinationLastTestResolver::class)->resolve($record); + } + + private function buildDeepLinkUrl(): ?string + { + $record = $this->record; + + if (! $record instanceof AlertDestination || $this->lastTestStatus?->deliveryId === null) { + return null; + } + + return AlertDeliveryResource::getUrl(panel: 'admin').'?'.http_build_query([ + 'filters' => [ + 'event_type' => ['value' => 'alerts.test'], + 'alert_destination_id' => ['value' => (string) $record->getKey()], + ], + ]); + } +} diff --git a/app/Models/AlertDelivery.php b/app/Models/AlertDelivery.php index e574ba3..122a3a1 100644 --- a/app/Models/AlertDelivery.php +++ b/app/Models/AlertDelivery.php @@ -4,7 +4,7 @@ namespace App\Models; -use App\Support\Concerns\DerivesWorkspaceIdFromTenant; +use App\Support\Concerns\DerivesWorkspaceIdFromTenantWhenPresent; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -13,10 +13,12 @@ class AlertDelivery extends Model { - use DerivesWorkspaceIdFromTenant; + use DerivesWorkspaceIdFromTenantWhenPresent; use HasFactory; use Prunable; + public const string EVENT_TYPE_TEST = 'alerts.test'; + public const string STATUS_QUEUED = 'queued'; public const string STATUS_DEFERRED = 'deferred'; diff --git a/app/Policies/AlertDeliveryPolicy.php b/app/Policies/AlertDeliveryPolicy.php index 643eb34..e27f82a 100644 --- a/app/Policies/AlertDeliveryPolicy.php +++ b/app/Policies/AlertDeliveryPolicy.php @@ -5,7 +5,6 @@ namespace App\Policies; use App\Models\AlertDelivery; -use App\Models\Tenant; use App\Models\User; use App\Models\Workspace; use App\Services\Auth\WorkspaceCapabilityResolver; @@ -40,8 +39,8 @@ public function view(User $user, AlertDelivery $alertDelivery): bool|Response $tenant = $alertDelivery->tenant; - if (! $tenant instanceof Tenant) { - return Response::denyAsNotFound(); + if ($tenant === null) { + return $this->authorizeForWorkspace($user, $workspace, Capabilities::ALERTS_VIEW); } if (! $user->canAccessTenant($tenant)) { diff --git a/app/Services/Alerts/AlertDestinationLastTestResolver.php b/app/Services/Alerts/AlertDestinationLastTestResolver.php new file mode 100644 index 0000000..8384f4d --- /dev/null +++ b/app/Services/Alerts/AlertDestinationLastTestResolver.php @@ -0,0 +1,71 @@ +where('workspace_id', (int) $destination->workspace_id) + ->where('alert_destination_id', (int) $destination->getKey()) + ->where('event_type', AlertDelivery::EVENT_TYPE_TEST) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($delivery === null) { + return AlertDestinationLastTestStatus::never(); + } + + $status = $this->mapStatus((string) $delivery->status); + $timestamp = $this->mapTimestamp($delivery, $status); + + return new AlertDestinationLastTestStatus( + status: $status, + timestamp: $timestamp, + deliveryId: (int) $delivery->getKey(), + ); + } + + private function mapStatus(string $deliveryStatus): AlertDestinationLastTestStatusEnum + { + return match ($deliveryStatus) { + AlertDelivery::STATUS_SENT => AlertDestinationLastTestStatusEnum::Sent, + AlertDelivery::STATUS_FAILED => AlertDestinationLastTestStatusEnum::Failed, + AlertDelivery::STATUS_QUEUED, + AlertDelivery::STATUS_DEFERRED => AlertDestinationLastTestStatusEnum::Pending, + default => AlertDestinationLastTestStatusEnum::Failed, + }; + } + + private function mapTimestamp(AlertDelivery $delivery, AlertDestinationLastTestStatusEnum $status): ?CarbonImmutable + { + return match ($status) { + AlertDestinationLastTestStatusEnum::Sent => $delivery->sent_at + ? CarbonImmutable::instance($delivery->sent_at) + : null, + AlertDestinationLastTestStatusEnum::Failed => $delivery->updated_at + ? CarbonImmutable::instance($delivery->updated_at) + : null, + AlertDestinationLastTestStatusEnum::Pending => $delivery->send_after + ? CarbonImmutable::instance($delivery->send_after) + : ($delivery->created_at ? CarbonImmutable::instance($delivery->created_at) : null), + AlertDestinationLastTestStatusEnum::Never => null, + }; + } +} diff --git a/app/Services/Alerts/AlertDestinationTestMessageService.php b/app/Services/Alerts/AlertDestinationTestMessageService.php new file mode 100644 index 0000000..3bc5b79 --- /dev/null +++ b/app/Services/Alerts/AlertDestinationTestMessageService.php @@ -0,0 +1,115 @@ +can('update', $destination)) { + throw new AuthorizationException('You do not have permission to send test messages for this destination.'); + } + + if (! $destination->is_enabled) { + return [ + 'success' => false, + 'message' => 'This destination is currently disabled. Enable it before sending a test message.', + 'delivery_id' => null, + ]; + } + + if ($this->isRateLimited($destination)) { + return [ + 'success' => false, + 'message' => 'A test message was sent recently. Please wait before trying again.', + 'delivery_id' => null, + ]; + } + + $delivery = $this->createTestDelivery($destination); + + $this->auditLog($destination, $actor); + + DeliverAlertsJob::dispatch((int) $destination->workspace_id); + + return [ + 'success' => true, + 'message' => 'Test message queued for delivery.', + 'delivery_id' => (int) $delivery->getKey(), + ]; + } + + private function isRateLimited(AlertDestination $destination): bool + { + return AlertDelivery::query() + ->where('workspace_id', (int) $destination->workspace_id) + ->where('alert_destination_id', (int) $destination->getKey()) + ->where('event_type', AlertDelivery::EVENT_TYPE_TEST) + ->where('created_at', '>=', now()->subSeconds(self::RATE_LIMIT_SECONDS)) + ->exists(); + } + + private function createTestDelivery(AlertDestination $destination): AlertDelivery + { + return AlertDelivery::create([ + 'workspace_id' => (int) $destination->workspace_id, + 'tenant_id' => null, + 'alert_rule_id' => null, + 'alert_destination_id' => (int) $destination->getKey(), + 'event_type' => AlertDelivery::EVENT_TYPE_TEST, + 'status' => AlertDelivery::STATUS_QUEUED, + 'severity' => null, + 'fingerprint_hash' => 'test:'.(int) $destination->getKey(), + 'attempt_count' => 0, + 'payload' => [ + 'title' => 'Test alert', + 'body' => 'This is a test delivery for destination verification.', + ], + ]); + } + + private function auditLog(AlertDestination $destination, User $actor): void + { + $workspace = $destination->workspace; + + if ($workspace === null) { + return; + } + + $this->auditLogger->log( + workspace: $workspace, + action: AuditActionId::AlertDestinationTestRequested->value, + context: [ + 'metadata' => [ + 'alert_destination_id' => (int) $destination->getKey(), + 'name' => (string) $destination->name, + 'type' => (string) $destination->type, + ], + ], + actor: $actor, + resourceType: 'alert_destination', + resourceId: (string) $destination->getKey(), + ); + } +} diff --git a/app/Support/Alerts/AlertDestinationLastTestStatus.php b/app/Support/Alerts/AlertDestinationLastTestStatus.php new file mode 100644 index 0000000..38c3782 --- /dev/null +++ b/app/Support/Alerts/AlertDestinationLastTestStatus.php @@ -0,0 +1,25 @@ +value => Domains\VerificationCheckStatusBadge::class, BadgeDomain::VerificationCheckSeverity->value => Domains\VerificationCheckSeverityBadge::class, BadgeDomain::VerificationReportOverall->value => Domains\VerificationReportOverallBadge::class, + BadgeDomain::AlertDeliveryStatus->value => Domains\AlertDeliveryStatusBadge::class, + BadgeDomain::AlertDestinationLastTestStatus->value => Domains\AlertDestinationLastTestStatusBadge::class, ]; /** diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index b8a93d4..9ce4b34 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -29,4 +29,6 @@ enum BadgeDomain: string case VerificationCheckStatus = 'verification_check_status'; case VerificationCheckSeverity = 'verification_check_severity'; case VerificationReportOverall = 'verification_report_overall'; + case AlertDeliveryStatus = 'alert_delivery_status'; + case AlertDestinationLastTestStatus = 'alert_destination_last_test_status'; } diff --git a/app/Support/Badges/Domains/AlertDeliveryStatusBadge.php b/app/Support/Badges/Domains/AlertDeliveryStatusBadge.php new file mode 100644 index 0000000..829805f --- /dev/null +++ b/app/Support/Badges/Domains/AlertDeliveryStatusBadge.php @@ -0,0 +1,28 @@ + new BadgeSpec('Queued', 'gray', 'heroicon-m-clock'), + AlertDelivery::STATUS_DEFERRED => new BadgeSpec('Deferred', 'warning', 'heroicon-m-clock'), + AlertDelivery::STATUS_SENT => new BadgeSpec('Sent', 'success', 'heroicon-m-check-circle'), + AlertDelivery::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), + AlertDelivery::STATUS_SUPPRESSED => new BadgeSpec('Suppressed', 'info', 'heroicon-m-no-symbol'), + AlertDelivery::STATUS_CANCELED => new BadgeSpec('Canceled', 'gray', 'heroicon-m-stop'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Badges/Domains/AlertDestinationLastTestStatusBadge.php b/app/Support/Badges/Domains/AlertDestinationLastTestStatusBadge.php new file mode 100644 index 0000000..5853737 --- /dev/null +++ b/app/Support/Badges/Domains/AlertDestinationLastTestStatusBadge.php @@ -0,0 +1,26 @@ +value => new BadgeSpec('Never', 'gray', 'heroicon-m-minus-circle'), + AlertDestinationLastTestStatusEnum::Sent->value => new BadgeSpec('Sent', 'success', 'heroicon-m-check-circle'), + AlertDestinationLastTestStatusEnum::Failed->value => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'), + AlertDestinationLastTestStatusEnum::Pending->value => new BadgeSpec('Pending', 'warning', 'heroicon-m-clock'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php b/app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php new file mode 100644 index 0000000..378fb67 --- /dev/null +++ b/app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php @@ -0,0 +1,134 @@ +getAttribute('tenant_id'); + + if ($tenantId === null || $tenantId === '') { + self::requireExplicitWorkspaceId($model); + + return; + } + + if (! is_numeric($tenantId)) { + throw WorkspaceIsolationViolation::missingTenantId(class_basename($model)); + } + + $tenantId = (int) $tenantId; + + self::ensureTenantIdIsImmutableOptional($model, $tenantId); + + $tenantWorkspaceId = self::resolveTenantWorkspaceIdOptional($model, $tenantId); + + $workspaceId = $model->getAttribute('workspace_id'); + + if ($workspaceId === null || $workspaceId === '') { + $model->setAttribute('workspace_id', $tenantWorkspaceId); + + return; + } + + if (! is_numeric($workspaceId)) { + throw WorkspaceIsolationViolation::workspaceMismatch( + class_basename($model), + $tenantId, + $tenantWorkspaceId, + 0, + ); + } + + $workspaceId = (int) $workspaceId; + + if ($workspaceId !== $tenantWorkspaceId) { + throw WorkspaceIsolationViolation::workspaceMismatch( + class_basename($model), + $tenantId, + $tenantWorkspaceId, + $workspaceId, + ); + } + } + + private static function requireExplicitWorkspaceId(Model $model): void + { + $workspaceId = $model->getAttribute('workspace_id'); + + if (! is_numeric($workspaceId) || (int) $workspaceId <= 0) { + throw WorkspaceIsolationViolation::missingTenantId( + class_basename($model).' (tenantless record requires explicit workspace_id)', + ); + } + } + + private static function ensureTenantIdIsImmutableOptional(Model $model, int $tenantId): void + { + if (! $model->exists || ! $model->isDirty('tenant_id')) { + return; + } + + $originalTenantId = $model->getOriginal('tenant_id'); + + if (! is_numeric($originalTenantId)) { + return; + } + + $originalTenantId = (int) $originalTenantId; + + if ($originalTenantId === $tenantId) { + return; + } + + throw WorkspaceIsolationViolation::tenantImmutable( + class_basename($model), + $originalTenantId, + $tenantId, + ); + } + + private static function resolveTenantWorkspaceIdOptional(Model $model, int $tenantId): int + { + $tenant = $model->relationLoaded('tenant') ? $model->getRelation('tenant') : null; + + if (! $tenant instanceof Tenant || (int) $tenant->getKey() !== $tenantId) { + $tenant = Tenant::query()->find($tenantId); + } + + if (! $tenant instanceof Tenant) { + throw WorkspaceIsolationViolation::tenantNotFound(class_basename($model), $tenantId); + } + + if (! is_numeric($tenant->workspace_id)) { + throw WorkspaceIsolationViolation::tenantWorkspaceMissing(class_basename($model), $tenantId); + } + + return (int) $tenant->workspace_id; + } +} diff --git a/database/factories/AlertDeliveryFactory.php b/database/factories/AlertDeliveryFactory.php index 080e917..b09ff2c 100644 --- a/database/factories/AlertDeliveryFactory.php +++ b/database/factories/AlertDeliveryFactory.php @@ -19,6 +19,8 @@ class AlertDeliveryFactory extends Factory public function definition(): array { return [ + // tenant_id and alert_rule_id are nullable; test() state sets them to null. + // Default definition creates a tenant-scoped delivery. 'tenant_id' => Tenant::factory(), 'workspace_id' => function (array $attributes): int { $tenantId = $attributes['tenant_id'] ?? null; @@ -75,4 +77,22 @@ public function definition(): array ], ]; } + + /** + * A tenantless test delivery for a specific destination. + */ + public function test(): static + { + return $this->state(fn (array $attributes): array => [ + 'tenant_id' => null, + 'alert_rule_id' => null, + 'event_type' => AlertDelivery::EVENT_TYPE_TEST, + 'severity' => null, + 'fingerprint_hash' => 'test:'.($attributes['alert_destination_id'] instanceof \Closure ? 0 : ($attributes['alert_destination_id'] ?? 0)), + 'payload' => [ + 'title' => 'Test alert', + 'body' => 'This is a test delivery for destination verification.', + ], + ]); + } } diff --git a/database/migrations/2026_02_18_000001_make_alert_deliveries_tenant_and_rule_nullable_for_test_deliveries.php b/database/migrations/2026_02_18_000001_make_alert_deliveries_tenant_and_rule_nullable_for_test_deliveries.php new file mode 100644 index 0000000..d9d2e77 --- /dev/null +++ b/database/migrations/2026_02_18_000001_make_alert_deliveries_tenant_and_rule_nullable_for_test_deliveries.php @@ -0,0 +1,31 @@ +unsignedBigInteger('tenant_id')->nullable()->change(); + $table->unsignedBigInteger('alert_rule_id')->nullable()->change(); + + $table->index( + ['workspace_id', 'alert_destination_id', 'event_type', 'created_at'], + 'alert_deliveries_last_test_lookup_index', + ); + }); + } + + public function down(): void + { + Schema::table('alert_deliveries', function (Blueprint $table): void { + $table->dropIndex('alert_deliveries_last_test_lookup_index'); + + $table->unsignedBigInteger('tenant_id')->nullable(false)->change(); + $table->unsignedBigInteger('alert_rule_id')->nullable(false)->change(); + }); + } +}; diff --git a/specs/100-alert-target-test-actions/checklists/requirements.md b/specs/100-alert-target-test-actions/checklists/requirements.md new file mode 100644 index 0000000..8925dee --- /dev/null +++ b/specs/100-alert-target-test-actions/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Alert Targets Test Actions + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-02-18 +**Feature**: [specs/100-alert-target-test-actions/spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Spec is ready for `/speckit.plan`. diff --git a/specs/100-alert-target-test-actions/contracts/event-types.md b/specs/100-alert-target-test-actions/contracts/event-types.md new file mode 100644 index 0000000..cac15c5 --- /dev/null +++ b/specs/100-alert-target-test-actions/contracts/event-types.md @@ -0,0 +1,17 @@ +# Contract — Alert Event Types (workspace alerts) + +## Overview + +This add-on introduces a single new event type used for *test deliveries*. + +## Event Type + +- `alerts.test` + - Purpose: user-triggered “Send test message” action on an alert target. + - Storage: `alert_deliveries.event_type` + - Delivery: sent via the existing alert delivery pipeline (`DeliverAlertsJob` + `AlertSender`). + +## Notes + +- This is an internal event type string; it is not a Microsoft Graph contract. +- No secrets are stored in payload/error text. diff --git a/specs/100-alert-target-test-actions/contracts/filament-deep-link-filters.md b/specs/100-alert-target-test-actions/contracts/filament-deep-link-filters.md new file mode 100644 index 0000000..fe00316 --- /dev/null +++ b/specs/100-alert-target-test-actions/contracts/filament-deep-link-filters.md @@ -0,0 +1,32 @@ +# Contract — Filament deep link into Alert Deliveries + +## Goal + +Allow a deterministic deep link from an Alert Target (destination) to the Deliveries viewer filtered to the most relevant rows. + +## Target + +- Resource: `AlertDeliveryResource` +- Page: list/index + +## Query-string filter contract (Filament v5) + +Use Filament’s `filters[...]` query-string state to pre-apply table filters: + +- Filter by event type: + - `filters[event_type][value]=alerts.test` +- Filter by destination: + - `filters[alert_destination_id][value]={DESTINATION_ID}` + +Combined example: + +`/admin/alert-deliveries?filters[event_type][value]=alerts.test&filters[alert_destination_id][value]=123` + +## Required table filters + +The Deliveries list must define these filters with matching keys: + +- `SelectFilter::make('event_type')` +- `SelectFilter::make('alert_destination_id')` + +(These are additive to the existing `status` filter.) diff --git a/specs/100-alert-target-test-actions/data-model.md b/specs/100-alert-target-test-actions/data-model.md new file mode 100644 index 0000000..f6f537c --- /dev/null +++ b/specs/100-alert-target-test-actions/data-model.md @@ -0,0 +1,79 @@ +# Phase 1 — Data Model (099.1 Add-on) + +## Entities + +### AlertDestination (existing) + +- **Table**: `alert_destinations` +- **Ownership**: workspace-owned +- **Key fields (relevant here)**: + - `id` + - `workspace_id` + - `type` (e.g. Teams webhook, Email) + - `config` (Teams webhook URL or list of recipients) + - `is_enabled` + +### AlertDelivery (existing, extended for test sends) + +- **Table**: `alert_deliveries` +- **Ownership**: + - v1 deliveries for real alerts remain tenant-associated + - **test deliveries for this add-on are tenantless** (workspace-only) + +- **Key fields (relevant here)**: + - `id` + - `workspace_id` (required) + - `tenant_id` (**nullable for test deliveries**) + - `alert_rule_id` (**nullable for test deliveries**) + - `alert_destination_id` (required) + - `event_type` (string, includes `alerts.test`) + - `status` (`queued|deferred|sent|failed|suppressed|canceled`) + - `send_after` (nullable; used for deferral/backoff) + - `sent_at` (nullable) + - `attempt_count` + - `last_error_code`, `last_error_message` (sanitized) + - `payload` (array/json) + - timestamps: `created_at`, `updated_at` + +## Relationships + +- `AlertDelivery` → `AlertDestination` (belongsTo via `alert_destination_id`) +- `AlertDelivery` → `AlertRule` (belongsTo via `alert_rule_id`, nullable) +- `AlertDelivery` → `Tenant` (belongsTo via `tenant_id`, nullable) + +## New derived concepts (no storage) + +### LastTestStatus (derived) + +Derived from the most recent `alert_deliveries` record where: +- `alert_destination_id = {destination}` +- `event_type = 'alerts.test'` + +Mapping: +- no record → `Never` +- `status in (queued, deferred)` → `Pending` +- `status = sent` → `Sent` +- `status = failed` → `Failed` + +Associated timestamp (derived): +- Sent → `sent_at` +- Failed → `updated_at` +- Pending → `send_after` (fallback `created_at`) + +## Validation / invariants + +- Creating a test delivery requires: + - `alert_destination_id` exists and belongs to current workspace + - destination is enabled (if disabled, refuse test request) + - rate limit: no prior test delivery for this destination in last 60 seconds +- Test delivery record must not persist secrets in payload or error message. + +## Migration notes + +To support tenantless test deliveries: +- Make `alert_deliveries.tenant_id` nullable and adjust the FK behavior. +- Make `alert_deliveries.alert_rule_id` nullable and adjust the FK behavior. +- Add or adjust indexes for efficient status lookup per destination + event type: + - `(workspace_id, alert_destination_id, event_type, created_at)` + +(Exact migration steps and DB constraint changes are specified in the implementation plan.) diff --git a/specs/100-alert-target-test-actions/plan.md b/specs/100-alert-target-test-actions/plan.md new file mode 100644 index 0000000..3ef7b63 --- /dev/null +++ b/specs/100-alert-target-test-actions/plan.md @@ -0,0 +1,221 @@ +# Implementation Plan: Alert Targets — Test Actions + Last Test Status + +**Branch**: `feat/100-alert-target-test-actions` | **Date**: 2026-02-18 | **Spec**: `specs/100-alert-target-test-actions/spec.md` +**Input**: Feature specification from `specs/100-alert-target-test-actions/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +Add a derived “Last test status” indicator to Alert Targets (Filament `AlertDestinationResource` view and edit pages), plus a capability-gated “Send test message” action that enqueues a test delivery (no outbound calls in the request thread). The status is derived solely from the latest `alert_deliveries` row with `event_type = 'alerts.test'` for the destination. + +## Technical Context + + + +**Language/Version**: PHP 8.4.15 (Laravel 12) +**Primary Dependencies**: Filament v5, Livewire v4, Laravel Queue, Laravel Notifications +**Storage**: PostgreSQL (Sail locally) +**Testing**: Pest v4 (via `vendor/bin/sail artisan test`) +**Target Platform**: Laravel web app (Sail-first locally; Dokploy containers in staging/prod) +**Project Type**: Web application (monolith) +**Performance Goals**: DB-only page rendering; avoid N+1; derived last-test status computed with a single indexed query +**Constraints**: No outbound network calls in request/response; no “last test” DB field; deterministic mapping (Never/Sent/Failed/Pending) +**Scale/Scope**: Workspace-scoped admin UX; minimal UI surfaces (view + edit pages) + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: N/A (no inventory changes) +- Read/write separation: test request is a small DB-only write; includes confirmation + audit + tests +- Graph contract path: no Graph calls +- Deterministic capabilities: uses existing `Capabilities::ALERTS_VIEW` / `Capabilities::ALERTS_MANAGE` registry +- RBAC-UX: existing workspace policy semantics (non-member 404 via policy) + member-without-capability 403 on action execution +- Workspace isolation: enforced via `WorkspaceContext` + `AlertDestinationPolicy` +- Destructive confirmations: “Send test message” is not destructive but still requires confirmation per spec +- Tenant isolation: deliveries viewer remains tenant-safe; tenantless test deliveries are treated as workspace-owned and are safe to reveal +- Run observability: external sends happen via existing `alerts.deliver` operation run created by `DeliverAlertsJob` +- Data minimization: test payload + errors are sanitized (no webhook URL / recipients) +- Badge semantics: new badge domains/mappers added; no ad-hoc mappings +- Filament action surface: edits are confined to edit header actions + read-only status section; action is capability-gated and audited + +## Project Structure + +### Documentation (this feature) + +```text +specs/100-alert-target-test-actions/ +├── plan.md +├── spec.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +└── checklists/ +``` + +### Source Code (repository root) + + +```text +app/ +├── Filament/ +│ └── Resources/ +│ ├── AlertDestinationResource.php +│ ├── AlertDeliveryResource.php +│ └── AlertDestinationResource/Pages/EditAlertDestination.php +├── Jobs/Alerts/ +│ └── DeliverAlertsJob.php +├── Models/ +│ └── AlertDelivery.php +├── Policies/ +│ ├── AlertDestinationPolicy.php +│ └── AlertDeliveryPolicy.php +├── Services/Alerts/ +│ ├── AlertSender.php +│ └── AlertDispatchService.php +└── Support/ + ├── Audit/AuditActionId.php + └── Badges/ + +database/migrations/ + +tests/ +├── Feature/ +└── Unit/ +``` + +**Structure Decision**: Laravel monolith; Filament resources under `app/Filament`, domain services/jobs under `app/Services` and `app/Jobs`, tests in `tests/`. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | + +## Phase 0 — Outline & Research (completed) + +Artifacts: + +- `specs/100-alert-target-test-actions/research.md` + +Key outcomes: + +- Deep link uses Filament v5 `filters[...]` query string. +- Timestamp mapping uses existing columns (`sent_at`, `updated_at`, `send_after`). +- Test deliveries must be tenantless to avoid tenant-entitlement divergence. + +## Phase 1 — Design & Contracts + +### Data model changes + +The current `alert_deliveries` table enforces tenant and rule FKs as required. For test sends: + +- Make `tenant_id` nullable (test deliveries have no tenant context) +- Make `alert_rule_id` nullable (test deliveries are not tied to a routing rule) + +Also add an index to support the derived “last test” lookup: + +- `(workspace_id, alert_destination_id, event_type, created_at)` + +### Backend design + +1. Add a small resolver to compute `LastTestStatus` from `alert_deliveries`: + - Input: `workspace_id`, `alert_destination_id` + - Query: latest delivery with `event_type = 'alerts.test'` + - Output: `{status, timestamp, delivery_id?}` + +2. Add a service for creating a test delivery: + - Authorize using existing `AlertDestinationPolicy::update` (manage capability) + - Enforce rate limit (60s) by checking latest test delivery `created_at` + - Create delivery with: + - `workspace_id` = current workspace + - `tenant_id` = null + - `alert_rule_id` = null + - `alert_destination_id` = destination id + - `event_type` = `alerts.test` + - `status` = `queued` + - `fingerprint_hash` = deterministic stable string (no secrets) (e.g. `test:{destination_id}`) + - `payload` = minimal safe text + +3. Queue execution: + - Dispatch `DeliverAlertsJob($workspaceId)` so the test is processed without waiting for a scheduler. + +### Filament UI design + +1. Alert Targets (View page) + - Provide a read-only view for view-only users. + - Render the derived “Last test” status (badge + timestamp). + - Add header actions: + - **Send test message**: visible but disabled when user lacks `ALERTS_MANAGE`. + - **View last delivery**: visible only if at least one test delivery exists. + +2. Alert Targets (Edit page) + - Add a read-only “Last test” status at the top of the edit form using a form component that can render as a badge. + - Add header actions: + - **Send test message** (mutating): `Action::make(...)->requiresConfirmation()->action(...)` + - Visible for workspace members with `ALERTS_VIEW`. + - Disabled via UI enforcement when user lacks `ALERTS_MANAGE`. + - **View last delivery** (navigation): `Action::make(...)->url(...)` + - Visible only if at least one test delivery exists. + +3. Deliveries viewer (List page) + - Add table filters: + - `event_type` (SelectFilter) + - `alert_destination_id` (SelectFilter) + - Adjust tenant entitlement filter to include tenantless deliveries (`tenant_id IS NULL`). + +### Authorization & semantics + +- Non-member: existing policies return `denyAsNotFound()`. +- Member without `ALERTS_MANAGE`: action execution must result in 403; UI remains visible but disabled. + +### Audit logging + +- Add a new `AuditActionId` value: `alert_destination.test_requested`. +- Log when a test is requested with redacted metadata (no webhook URL / recipients). + +### Badge semantics + +- Add badge domain(s) and tests: + - Alert delivery status badge mapping (to replace the ad-hoc mapping in `AlertDeliveryResource`). + - Alert destination last-test status badge mapping. + +### Contracts + +- Deep link contract + event type contract live in `specs/100-alert-target-test-actions/contracts/`. + +## Post-Design Constitution Re-check + +- RBAC-UX: enforced via policies + capability registry; test action server-authorized. +- BADGE-001: new badge domains + tests planned; no ad-hoc mappings. +- OPS/run observability: outbound delivery occurs only in queued job; `alerts.deliver` operation run remains the monitoring source. +- DB-only rendering: derived status and links use indexed DB queries; no external calls. + +## Phase 2 — Implementation planning (for tasks.md) + +Next steps (to be expanded into `tasks.md`): + +1. DB migration: make `alert_deliveries.tenant_id` and `alert_rule_id` nullable + add supporting index. +2. Update `AlertDelivery` model/relationships/casts if needed for tenantless + ruleless deliveries. +3. Update `AlertDeliveryPolicy` + `AlertDeliveryResource` query to allow tenantless deliveries. +4. Add badge domains + mapping tests. +5. Add “Send test message” header action + “Last test” badge section to `EditAlertDestination`. +6. Add feature tests (Pest) for: + - derived status mapping (Never/Sent/Failed/Pending) + - rate limiting + - RBAC (manage vs view) + - deep link visibility diff --git a/specs/100-alert-target-test-actions/quickstart.md b/specs/100-alert-target-test-actions/quickstart.md new file mode 100644 index 0000000..6b8b18d --- /dev/null +++ b/specs/100-alert-target-test-actions/quickstart.md @@ -0,0 +1,20 @@ +# Quickstart (099.1 Add-on) + +## Prereqs + +- Laravel Sail is used for local dev. + +## Run locally + +- Start containers: `vendor/bin/sail up -d` +- Run migrations: `vendor/bin/sail artisan migrate` + +## Run tests (focused) + +- `vendor/bin/sail artisan test --compact --filter=AlertTargetTest` + +(Replace filter/name with the final Pest test names once implemented.) + +## Format + +- `vendor/bin/sail bin pint --dirty` diff --git a/specs/100-alert-target-test-actions/research.md b/specs/100-alert-target-test-actions/research.md new file mode 100644 index 0000000..5890607 --- /dev/null +++ b/specs/100-alert-target-test-actions/research.md @@ -0,0 +1,68 @@ +# Phase 0 — Research (099.1 Add-on) + +This research resolves the open technical questions from the feature spec and anchors decisions to existing code in this repository. + +## Decision 1 — Where “Alert Target View/Edit” lives in code + +- **Decision**: Implement “Last test status” + “Send test message” on the Alert Target **Edit** page only. +- **Rationale**: The current Filament resource for alert targets is `AlertDestinationResource` and only defines `index/create/edit` pages (no View page). +- **Alternatives considered**: + - Add a new View page for `AlertDestinationResource` (rejected: expands UX surface and is not required for this add-on). + +## Decision 2 — Canonical event type string + +- **Decision**: Use a single canonical event type string for test sends: `alerts.test`. +- **Rationale**: The add-on spec requires deriving status from `alert_deliveries.event_type`. Keeping this string stable allows deterministic filtering and deep links. +- **Alternatives considered**: + - Use `test` or `destination_test` (rejected: deviates from spec and makes intent less explicit). + +## Decision 3 — Timestamp mapping vs actual schema + +- **Decision**: Map timestamps using existing columns: + - **Sent** → `sent_at` + - **Failed** → `updated_at` (no `failed_at` column exists) + - **Pending** → `send_after` if set, else `created_at` +- **Rationale**: The `alert_deliveries` schema only has `send_after`, `sent_at`, and `updated_at`. The spec’s `deliver_at` / `failed_at` naming is treated as conceptual. +- **Alternatives considered**: + - Add `failed_at` / `deliver_at` columns (rejected: violates “no new DB fields”). + +## Decision 4 — How to represent test deliveries given current tenant enforcement + +- **Decision**: Store test deliveries in `alert_deliveries` as **workspace-scoped, tenantless** records: + - `workspace_id` set + - `tenant_id` nullable + - `alert_rule_id` nullable + - `alert_destination_id` set +- **Rationale**: + - Alert targets (`AlertDestination`) are workspace-owned and accessible without a selected tenant. + - Current enforcement (`DerivesWorkspaceIdFromTenant`) requires a tenant and would make test deliveries invisible/uneven across users due to tenant entitlements. + - Deliver job execution (`DeliverAlertsJob`) does not require a tenant; it only needs the destination + payload. +- **Alternatives considered**: + - Pick an arbitrary tenant ID for test deliveries (rejected: would be invisible to some operators and breaks “single truth” per target). + - Create a synthetic “workspace tenant” record (rejected: adds data-model complexity). + +## Decision 5 — Deep link mechanics for the Deliveries viewer + +- **Decision**: Deep link into `AlertDeliveryResource` list view using Filament v5 filter query-string binding: + - `?filters[event_type][value]=alerts.test&filters[alert_destination_id][value]={DESTINATION_ID}` +- **Rationale**: Filament v5 `ListRecords` binds `tableFilters` to the URL under the `filters` key; `SelectFilter` uses `value`. +- **Alternatives considered**: + - Custom dedicated “Test deliveries” page (rejected: new surface beyond spec). + +## Decision 6 — Badge semantics centralization (BADGE-001) + +- **Decision**: Introduce centralized badge domains/mappers for: + - `AlertDeliveryStatus` (queued/deferred/sent/failed/suppressed/canceled) + - `AlertDestinationLastTestStatus` (never/pending/sent/failed) +- **Rationale**: Current `AlertDeliveryResource` implements ad-hoc status label/color helpers. This add-on must comply with BADGE-001 and should not add more local mappings. +- **Alternatives considered**: + - Keep ad-hoc mappings in the new code (rejected: violates BADGE-001). + +## Decision 7 — OperationRun usage + +- **Decision**: Do **not** create a new per-test `OperationRun`. +- **Rationale**: + - The user-triggered action is DB-only (authorization + rate limit + create delivery + audit), typically <2s. + - The external work runs via the existing `alerts.deliver` operation run (created by `DeliverAlertsJob`). +- **Alternatives considered**: + - Create a dedicated operation run type (rejected: higher complexity for small UX add-on). diff --git a/specs/100-alert-target-test-actions/spec.md b/specs/100-alert-target-test-actions/spec.md new file mode 100644 index 0000000..f568257 --- /dev/null +++ b/specs/100-alert-target-test-actions/spec.md @@ -0,0 +1,156 @@ +# Feature Specification: Alert Targets Test Actions + +**Feature Branch**: `feat/100-alert-target-test-actions` +**Created**: 2026-02-18 +**Status**: Draft +**Input**: User description: "099.1 — Alert Targets: Send Test Message + Last Test Status (Teams + Email)" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: workspace +- **Primary Routes**: Alert Target View, Alert Target Edit, Deliveries viewer (filtered deep links) +- **Data Ownership**: + - Workspace-owned: alert targets + - Tenant-owned: alert delivery history (non-test) + - Workspace-scoped: test deliveries (`event_type=alerts.test`) may be tenantless (`tenant_id` nullable) +- **RBAC**: + - Workspace membership is required to access Alert Targets and Deliveries. + - Users with manage capability can request a test send. + - Users with view-only capability can see test status but cannot request a test send. + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: Not applicable (workspace scope). +- **Explicit entitlement checks preventing cross-tenant leakage**: Targets and deliveries are only visible within the current workspace; non-members must not receive any existence hints. + +## Clarifications + +### Session 2026-02-18 + +- Q: Which timestamps should the “Last test … at ” subtext use? → A: Sent → `sent_at`; Failed → `updated_at`; Pending → `send_after`. +- Q: What should the test-send rate limit be per target? → A: 60 seconds per target. +- Q: What should “View last delivery” open? → B: Deliveries list viewer filtered to `alert_destination_id` + `event_type=alerts.test`. +- Q: When should “View last delivery” be shown on the Alert Target pages? → B: Show only if at least one test delivery exists. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Send a test message for a target (Priority: P1) + +As an admin, I want to send a test alert to a configured Alert Target so I can verify the integration works before relying on it in production. + +**Why this priority**: This is the fastest way to detect misconfiguration (wrong destination, blocked network path, invalid credentials) and reduce support/incident time. + +**Independent Test**: Can be fully tested by requesting a test send and confirming a new test delivery record exists and can be inspected. + +**Acceptance Scenarios**: + +1. **Given** I am a workspace member with manage permission, **When** I confirm “Send test message” on an Alert Target, **Then** the system creates exactly one new test delivery record for that target and indicates the request was queued. +2. **Given** I am a workspace member without manage permission, **When** I attempt to execute “Send test message”, **Then** the action is blocked and no delivery record is created. +3. **Given** I have requested a test very recently for the same target, **When** I attempt another test immediately, **Then** the system refuses the request and does not create a new delivery record. + +--- + +### User Story 2 - See the last test status at a glance (Priority: P2) + +As an admin, I want to see whether the last test send for an Alert Target succeeded, failed, was never executed, or is still pending so I can assess health without digging through deliveries. + +**Why this priority**: Health visibility reduces troubleshooting time and prevents silent failures. + +**Independent Test**: Can be fully tested by viewing a target with (a) no test deliveries, (b) a successful test delivery, (c) a failed test delivery and verifying the badge and timestamp. + +**Acceptance Scenarios**: + +1. **Given** a target has no test delivery records, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Never”. +2. **Given** the most recent test delivery record is successful, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Sent” and an associated timestamp. +3. **Given** the most recent test delivery record is failed, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Failed” and an associated timestamp. +4. **Given** the most recent test delivery record is queued or deferred, **When** I open the target View or Edit page, **Then** I see a badge “Last test: Pending”. + +--- + +### User Story 3 - Jump from target to the relevant delivery details (Priority: P3) + +As an admin, I want a quick link from the Alert Target to the most recent test delivery details so I can troubleshoot outcomes efficiently. + +**Why this priority**: It reduces clicks and prevents mistakes when searching through delivery history. + +**Independent Test**: Can be tested by clicking a “View last delivery” link and verifying the deliveries view is pre-filtered for this target and the test event type. + +**Acceptance Scenarios**: + +1. **Given** a target has at least one test delivery record, **When** I click “View last delivery” from the target page, **Then** I am taken to the deliveries list viewer scoped to the same workspace and filtered to that target and the test event type. + +### Edge Cases + +- Target exists but has never been tested. +- Target has multiple test deliveries; only the most recent one is used for status. +- The most recent test delivery is queued/deferred; status must show pending without implying success. +- Users without workspace membership attempt to access targets or deliveries (must be deny-as-not-found). +- Failure details must not expose secrets (destination URLs, recipients). +- Rapid repeated test requests (anti-spam / rate limiting) must not create additional delivery records. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature introduces a user-triggered action that creates a delivery record and schedules work to be executed asynchronously. +The spec requires: confirmation, RBAC enforcement, anti-spam rate limiting, audit logging, tenant/workspace isolation, and tests. + +**Constitution alignment (RBAC-UX):** + +- Authorization planes involved: Admin UI (workspace-scoped). +- 404 vs 403 semantics: + - Non-member / not entitled to workspace scope → 404 (deny-as-not-found) + - Workspace member but missing manage capability → 403 for executing the test action +- All mutations (test request) require server-side authorization. + +**Constitution alignment (BADGE-001):** “Last test: Sent/Failed/Pending/Never” MUST use centralized badge semantics (no ad-hoc mappings). + +**Constitution alignment (Filament Action Surfaces):** This feature modifies Filament pages (Alert Target view/edit) and therefore includes a UI Action Matrix. + +### Functional Requirements + +- **FR-001 (Derived status, no new fields)**: The system MUST display a “Last test status” indicator on Alert Target View and Edit pages derived solely from existing alert delivery records. +- **FR-002 (Deterministic selection)**: The “Last test status” MUST be derived from the single most recent delivery record for the given target where the delivery event type is “alerts.test”, ordered by `created_at` (desc) then `id` (desc). +- **FR-003 (Status mapping)**: The system MUST map the most recent test delivery record to one of: Never (no record), Sent, Failed, or Pending. +- **FR-004 (Timestamp semantics)**: The UI MUST display a timestamp that reflects when the outcome occurred: Sent → `sent_at`; Failed → `updated_at`; Pending → `send_after`. +- **FR-005 (DB-only UI)**: Requesting a test send MUST not perform synchronous external delivery attempts in the user’s request/response flow. +- **FR-006 (Confirmation)**: The “Send test message” action MUST require explicit confirmation, explaining that it will contact the configured destination. +- **FR-007 (Anti-spam rate limit)**: The system MUST prevent repeated test requests for the same target within 60 seconds. +- **FR-008 (RBAC)**: Only workspace members with manage permission can request a test send; view-only users can see the action but cannot execute it. +- **FR-009 (Deny-as-not-found)**: Users without workspace membership MUST receive deny-as-not-found behavior for targets and deliveries. +- **FR-010 (Auditability)**: The system MUST record an audit event when a test is requested, without including destination secrets. +- **FR-011 (Deep link)**: The system SHOULD provide a “View last delivery” link from the target to the Deliveries list viewer filtered to that target and `event_type=alerts.test`. +- **FR-012 (Deep link visibility)**: The system SHOULD show “View last delivery” only when at least one test delivery exists for the target. + +### Assumptions + +- Alerts v1 already provides Alert Targets, Alert Deliveries, and a Deliveries viewer. +- A test send is represented as a delivery record with event type “alerts.test”. + +### Non-Goals + +- No new database fields for storing “last test status”. +- No bulk “test all targets” feature. +- No destination setup wizard. +- No per-row list view badge (avoids performance/N+1 concerns in v1). + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Alert Target (View) | Alert Target view page | Send test message (confirm, manage-only), View last delivery (navigate) | Not changed in this feature | Not changed in this feature | None | None | Same as Header Actions | Not applicable | Yes | View-only users see disabled “Send test message”. | +| Alert Target (Edit) | Alert Target edit page | Send test message (confirm, manage-only), View last delivery (navigate) | Not changed in this feature | Not changed in this feature | None | None | Same as Header Actions | Existing save/cancel | Yes | “Last test status” appears above the form. | +| Deliveries viewer | Deliveries list/details | None (existing) | Filtered via deep link | Existing row actions | Existing | Existing | Existing | Not applicable | Existing | Must remain workspace-scoped. | + +### Key Entities *(include if feature involves data)* + +- **Alert Target**: A destination configuration for alerts (e.g., Teams webhook or email destination), scoped to a workspace. +- **Alert Delivery**: A delivery attempt record that captures event type (including “alerts.test”), status, timestamps, and safe diagnostic details. +- **Audit Event**: A workspace-scoped audit entry representing a user-triggered test request. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: An admin can determine “last test status” for a target within 5 seconds from the target page. +- **SC-002**: An admin can request a test send in under 30 seconds (including confirmation) without leaving the target page. +- **SC-003**: A test request always results in either (a) a new test delivery record being created, or (b) a clear refusal due to rate limiting or missing permissions. +- **SC-004**: Troubleshooting time for “alerts not delivered” issues is reduced because the last test outcome and a direct link to details are immediately available. diff --git a/specs/100-alert-target-test-actions/tasks.md b/specs/100-alert-target-test-actions/tasks.md new file mode 100644 index 0000000..96df23f --- /dev/null +++ b/specs/100-alert-target-test-actions/tasks.md @@ -0,0 +1,180 @@ +--- + +description: "Task list for 100 — Alert Targets: Send Test Message + Last Test Status (input: 099.1)" +--- + +# Tasks: Alert Targets — Send Test Message + Last Test Status (Teams + Email) + +**Input**: Design documents from `specs/100-alert-target-test-actions/` + +**Prerequisites**: +- `specs/100-alert-target-test-actions/spec.md` (user stories + priorities) +- `specs/100-alert-target-test-actions/plan.md` (implementation approach) +- `specs/100-alert-target-test-actions/research.md` (decisions) +- `specs/100-alert-target-test-actions/data-model.md` (tenantless test deliveries) +- `specs/100-alert-target-test-actions/contracts/` (event type + deep link filters) + +**Tests**: REQUIRED (Pest) for all runtime behavior changes. + +## Format: `- [ ] [TaskID] [P?] [Story?] Description with file path` + +- **[P]**: can run in parallel (different files, no dependencies on incomplete tasks) +- **[US1] [US2] [US3]**: user story mapping (required for story phases) + +--- + +## Phase 1: Setup (Shared) + +**Purpose**: Ensure local dev/test workflow is concrete for this feature. + +- [X] T001 Update test quickstart commands in specs/100-alert-target-test-actions/quickstart.md + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Enable tenantless test deliveries + keep workspace isolation and RBAC correct. + +- [X] T002 Create migration to allow tenantless test deliveries + add supporting index in database/migrations/2026_02_18_000001_make_alert_deliveries_tenant_and_rule_nullable_for_test_deliveries.php +- [X] T003 Create tenant-optional workspace isolation trait in app/Support/Concerns/DerivesWorkspaceIdFromTenantWhenPresent.php +- [X] T004 Switch AlertDelivery to tenant-optional isolation in app/Models/AlertDelivery.php +- [X] T005 Add AlertDeliveryFactory support for tenantless deliveries in database/factories/AlertDeliveryFactory.php +- [X] T006 Add audit action id for test request in app/Support/Audit/AuditActionId.php + +**Checkpoint**: Tenantless `alert_deliveries` records can be created safely, and audit IDs exist. + +--- + +## Phase 3: User Story 1 — Send a test message for a target (Priority: P1) 🎯 MVP + +**Goal**: Capability-gated “Send test message” action creates a test delivery record and queues delivery asynchronously. + +**Independent Test**: A manage-capable workspace member can request a test send; a new `alert_deliveries` row exists with `event_type=alerts.test`, and the queue has a `DeliverAlertsJob`. + +### Tests (write first) + +- [X] T007 [P] [US1] Add send-test action happy-path test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php +- [X] T008 [P] [US1] Add rate-limit refusal test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php +- [X] T009 [P] [US1] Add authorization (readonly forbidden) test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php +- [X] T010 [P] [US1] Add non-member deny-as-not-found (404) regression test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php +- [X] T011 [P] [US1] Add audit log assertion test in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php +- [X] T012 [P] [US1] Add confirmation requirement test for the Filament action in tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php + +### Implementation + +- [X] T013 [US1] Add canonical test event type constant in app/Models/AlertDelivery.php +- [X] T014 [US1] Implement test delivery creation service in app/Services/Alerts/AlertDestinationTestMessageService.php +- [X] T015 [US1] Add "Send test message" header action on edit page in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php + +**Checkpoint**: US1 passes its feature tests and performs no outbound calls in the request thread. + +--- + +## Phase 4: User Story 2 — See the last test status at a glance (Priority: P2) + +**Goal**: Show derived “Last test: Never/Sent/Failed/Pending” badge with deterministic, DB-only semantics. + +**Independent Test**: With controlled `alert_deliveries` rows, the target page shows the correct derived status and timestamp without external calls. + +### Tests (write first) + +- [X] T016 [P] [US2] Add badge semantics tests for delivery + last-test domains in tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php +- [X] T017 [P] [US2] Add derived last-test mapping test (never/sent/failed/pending) in tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php + +### Implementation + +- [ ] T018 [P] [US2] Add last-test status enum + result DTO in app/Support/Alerts/AlertDestinationLastTestStatus.php +- [ ] T019 [US2] Implement last-test resolver (latest alerts.test row) in app/Services/Alerts/AlertDestinationLastTestResolver.php +- [ ] T020 [US2] Add badge domains for alert delivery + destination last-test in app/Support/Badges/BadgeDomain.php +- [ ] T021 [US2] Register new badge mappers in app/Support/Badges/BadgeCatalog.php +- [ ] T022 [P] [US2] Implement badge mapper for delivery status in app/Support/Badges/Domains/AlertDeliveryStatusBadge.php +- [ ] T023 [P] [US2] Implement badge mapper for destination last-test status in app/Support/Badges/Domains/AlertDestinationLastTestStatusBadge.php +- [ ] T024 [US2] Refactor delivery status badges to use BadgeCatalog in app/Filament/Resources/AlertDeliveryResource.php +- [ ] T025 [US2] Add read-only View page for alert destinations in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php +- [ ] T026 [US2] Register view page and route view-only users from list in app/Filament/Resources/AlertDestinationResource.php +- [ ] T027 [US2] Render “Last test” badge + timestamp on view page in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php +- [ ] T028 [US2] Render “Last test” badge + timestamp on edit page in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php + +**Checkpoint**: US2 is fully readable by view-only users (via View page) and meets BADGE-001. + +--- + +## Phase 5: User Story 3 — Jump from target to the relevant delivery details (Priority: P3) + +**Goal**: Provide a “View last delivery” deep link into the Deliveries viewer filtered by `alerts.test` and destination. + +**Independent Test**: When at least one test delivery exists, the link appears and routes to `/admin/alert-deliveries` with the correct `filters[...]` query string. + +### Tests (write first) + +- [X] T029 [P] [US3] Add deep-link visibility + URL contract test in tests/Feature/Alerts/AlertDestinationViewLastDeliveryLinkTest.php +- [X] T030 [P] [US3] Add deliveries viewer filter coverage test in tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php + +### Implementation + +- [X] T031 [US3] Add table filters for event_type + alert_destination_id in app/Filament/Resources/AlertDeliveryResource.php +- [X] T032 [US3] Allow tenantless deliveries in deliveries list query in app/Filament/Resources/AlertDeliveryResource.php +- [X] T033 [US3] Update tenant entitlement policy to allow tenantless delivery records in app/Policies/AlertDeliveryPolicy.php +- [X] T034 [US3] Add "View last delivery" header action (view page) in app/Filament/Resources/AlertDestinationResource/Pages/ViewAlertDestination.php +- [X] T035 [US3] Add "View last delivery" header action (edit page) in app/Filament/Resources/AlertDestinationResource/Pages/EditAlertDestination.php + +**Checkpoint**: US3 deep links match the contract in specs/100-alert-target-test-actions/contracts/filament-deep-link-filters.md. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T036 [P] Run Pint on changed files with vendor/bin/sail bin pint --dirty +- [X] T037 Run focused test suite for this feature with vendor/bin/sail artisan test --compact tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php +- [X] T038 Run focused badge tests with vendor/bin/sail artisan test --compact tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php + +--- + +## Dependencies & Execution Order + +### Dependency graph (user stories) + +```mermaid +graph TD + F[Phase 2: Foundational] --> US1[US1: Send test message] + F --> US2[US2: Last test status] + US2 --> US3[US3: View last delivery deep link] +``` + +- **Foundational (Phase 2)** blocks all user stories. +- **US1** can be delivered as MVP after Phase 2. +- **US2** adds read-only visibility and centralized badge semantics. +- **US3** depends on US2 resolver/view surface (or duplicate a small existence query if needed). + +--- + +## Parallel execution examples + +### US1 + +- `T007–T012` can be written in parallel (single test file, but separate assertions can be split across commits). +- `T013` (model constant) and `T014` (service) can be done before `T015` (Filament action). + +### US2 + +- `T022` and `T023` (badge mappers) can be implemented in parallel. +- `T018` (enum/DTO) and `T019` (resolver) can be done before wiring UI. + +### US3 + +- `T031–T033` (deliveries viewer plumbing) can proceed in parallel with `T034–T035` (target header actions) once the resolver is available. + +--- + +## Implementation strategy + +### MVP first + +1. Complete Phase 2 (Foundational) +2. Implement and ship US1 (Send test message) +3. Validate with focused tests + +### Incremental delivery + +- Add US2 (Last test status + View page) after US1 is stable +- Add US3 (Deep link + deliveries filters) last diff --git a/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php b/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php new file mode 100644 index 0000000..ee3344f --- /dev/null +++ b/tests/Feature/Alerts/AlertDeliveryDeepLinkFiltersTest.php @@ -0,0 +1,118 @@ +actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $testDelivery = AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + $normalDelivery = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'alert_rule_id' => $rule->getKey(), + 'alert_destination_id' => $destination->getKey(), + 'event_type' => 'high_drift', + 'status' => AlertDelivery::STATUS_SENT, + ]); + + Livewire::test(ListAlertDeliveries::class) + ->assertCanSeeTableRecords([$testDelivery, $normalDelivery]) + ->filterTable('event_type', AlertDelivery::EVENT_TYPE_TEST) + ->assertCanSeeTableRecords([$testDelivery]) + ->assertCanNotSeeTableRecords([$normalDelivery]); +}); + +it('filters deliveries by alert_destination_id', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $destinationA = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + $destinationB = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $deliveryA = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'alert_rule_id' => $rule->getKey(), + 'alert_destination_id' => $destinationA->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + $deliveryB = AlertDelivery::factory()->create([ + 'workspace_id' => $workspaceId, + 'tenant_id' => $tenant->getKey(), + 'alert_rule_id' => $rule->getKey(), + 'alert_destination_id' => $destinationB->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + Livewire::test(ListAlertDeliveries::class) + ->assertCanSeeTableRecords([$deliveryA, $deliveryB]) + ->filterTable('alert_destination_id', (string) $destinationA->getKey()) + ->assertCanSeeTableRecords([$deliveryA]) + ->assertCanNotSeeTableRecords([$deliveryB]); +}); + +it('includes tenantless test deliveries in the list', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + $testDelivery = AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + Livewire::test(ListAlertDeliveries::class) + ->assertCanSeeTableRecords([$testDelivery]); +}); diff --git a/tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php b/tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php new file mode 100644 index 0000000..72e6b5d --- /dev/null +++ b/tests/Feature/Alerts/AlertDestinationLastTestStatusTest.php @@ -0,0 +1,181 @@ +actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $resolver = new AlertDestinationLastTestResolver; + $result = $resolver->resolve($destination); + + expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Never); + expect($result->timestamp)->toBeNull(); + expect($result->deliveryId)->toBeNull(); +}); + +it('returns Sent status when the latest test delivery was sent', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $delivery = AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + 'sent_at' => now()->subMinutes(5), + ]); + + $resolver = new AlertDestinationLastTestResolver; + $result = $resolver->resolve($destination); + + expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Sent); + expect($result->deliveryId)->toBe((int) $delivery->getKey()); + expect($result->timestamp)->not->toBeNull(); +}); + +it('returns Failed status when the latest test delivery failed', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $delivery = AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + ]); + + $resolver = new AlertDestinationLastTestResolver; + $result = $resolver->resolve($destination); + + expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Failed); + expect($result->deliveryId)->toBe((int) $delivery->getKey()); +}); + +it('returns Pending status when the latest test delivery is queued', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $delivery = AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_QUEUED, + ]); + + $resolver = new AlertDestinationLastTestResolver; + $result = $resolver->resolve($destination); + + expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Pending); + expect($result->deliveryId)->toBe((int) $delivery->getKey()); +}); + +it('returns Pending status when the latest test delivery is deferred', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $delivery = AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_DEFERRED, + 'send_after' => now()->addMinutes(5), + ]); + + $resolver = new AlertDestinationLastTestResolver; + $result = $resolver->resolve($destination); + + expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Pending); + expect($result->deliveryId)->toBe((int) $delivery->getKey()); +}); + +it('uses the most recent test delivery when multiple exist', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + 'sent_at' => now()->subHour(), + 'created_at' => now()->subHour(), + ]); + + $latestDelivery = AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => (int) $destination->getKey(), + 'status' => AlertDelivery::STATUS_FAILED, + 'created_at' => now()->subMinutes(5), + ]); + + $resolver = new AlertDestinationLastTestResolver; + $result = $resolver->resolve($destination); + + expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Failed); + expect($result->deliveryId)->toBe((int) $latestDelivery->getKey()); +}); + +it('ignores non-test deliveries when resolving last test status', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + $rule = AlertRule::factory()->create([ + 'workspace_id' => $workspaceId, + ]); + + 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' => 'high_drift', + 'status' => AlertDelivery::STATUS_SENT, + 'created_at' => now(), + ]); + + $resolver = new AlertDestinationLastTestResolver; + $result = $resolver->resolve($destination); + + expect($result->status)->toBe(AlertDestinationLastTestStatusEnum::Never); +}); diff --git a/tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php b/tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php new file mode 100644 index 0000000..c857e52 --- /dev/null +++ b/tests/Feature/Alerts/AlertDestinationSendTestMessageTest.php @@ -0,0 +1,178 @@ +actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->callAction('send_test_message'); + + $delivery = AlertDelivery::query() + ->where('alert_destination_id', $destination->getKey()) + ->where('event_type', AlertDelivery::EVENT_TYPE_TEST) + ->first(); + + expect($delivery)->not->toBeNull(); + expect($delivery->workspace_id)->toBe($workspaceId); + expect($delivery->tenant_id)->toBeNull(); + expect($delivery->alert_rule_id)->toBeNull(); + expect($delivery->status)->toBe(AlertDelivery::STATUS_QUEUED); + + Queue::assertPushed(DeliverAlertsJob::class, function (DeliverAlertsJob $job) use ($workspaceId): bool { + return $job->workspaceId === $workspaceId; + }); +}); + +// --------------------------------------------------------------------------- +// T008 — Rate-limit refusal: second request within 60 seconds is rejected +// --------------------------------------------------------------------------- +it('refuses a second test message within 60 seconds', function (): void { + Queue::fake(); + + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => (int) $destination->getKey(), + 'created_at' => now()->subSeconds(30), + ]); + + Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->callAction('send_test_message') + ->assertNotified(); + + expect( + AlertDelivery::query() + ->where('alert_destination_id', $destination->getKey()) + ->where('event_type', AlertDelivery::EVENT_TYPE_TEST) + ->count() + )->toBe(1); + + Queue::assertNothingPushed(); +}); + +// --------------------------------------------------------------------------- +// T009 — Authorization: readonly member is forbidden from edit page +// --------------------------------------------------------------------------- +it('forbids readonly members from accessing the edit page for destinations', function (): void { + Queue::fake(); + + [$user] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + $this->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin')) + ->assertForbidden(); + + Queue::assertNothingPushed(); +}); + +// --------------------------------------------------------------------------- +// T010 — Non-member: denied as not found (404) +// --------------------------------------------------------------------------- +it('returns 404 for non-member trying to access edit page with send test action', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + + $otherWorkspace = Workspace::factory()->create(); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => (int) $otherWorkspace->getKey(), + 'is_enabled' => true, + ]); + + $this->actingAs($user) + ->get(AlertDestinationResource::getUrl('edit', ['record' => $destination], panel: 'admin')) + ->assertNotFound(); +}); + +// --------------------------------------------------------------------------- +// T011 — Audit log assertion: test request is audit-logged +// --------------------------------------------------------------------------- +it('creates an audit log entry when a test message is sent', function (): void { + Queue::fake(); + + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->callAction('send_test_message'); + + $auditLog = AuditLog::query() + ->where('workspace_id', $workspaceId) + ->where('action', AuditActionId::AlertDestinationTestRequested->value) + ->where('resource_type', 'alert_destination') + ->where('resource_id', (string) $destination->getKey()) + ->first(); + + expect($auditLog)->not->toBeNull(); + expect($auditLog->actor_id)->toBe((int) $user->getKey()); +}); + +// --------------------------------------------------------------------------- +// T012 — Confirmation requirement: action requires confirmation before execution +// --------------------------------------------------------------------------- +it('requires confirmation before sending a test message', function (): void { + Queue::fake(); + + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->assertActionExists('send_test_message') + ->mountAction('send_test_message') + ->assertActionMounted('send_test_message'); + + expect(AlertDelivery::query()->count())->toBe(0); + Queue::assertNothingPushed(); +}); diff --git a/tests/Feature/Alerts/AlertDestinationViewLastDeliveryLinkTest.php b/tests/Feature/Alerts/AlertDestinationViewLastDeliveryLinkTest.php new file mode 100644 index 0000000..4f5412e --- /dev/null +++ b/tests/Feature/Alerts/AlertDestinationViewLastDeliveryLinkTest.php @@ -0,0 +1,110 @@ +actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->assertActionVisible('view_last_delivery'); +}); + +it('hides view_last_delivery action on view page when no test delivery exists', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->assertActionHidden('view_last_delivery'); +}); + +it('shows view_last_delivery action on edit page when a test delivery exists', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->assertActionVisible('view_last_delivery'); +}); + +it('hides view_last_delivery action on edit page when no test delivery exists', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + Livewire::test(EditAlertDestination::class, ['record' => $destination->getRouteKey()]) + ->assertActionHidden('view_last_delivery'); +}); + +it('generates deep link URL matching the contract filters', function (): void { + [$user] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY); + $destination = AlertDestination::factory()->create([ + 'workspace_id' => $workspaceId, + 'is_enabled' => true, + ]); + + AlertDelivery::factory()->test()->create([ + 'workspace_id' => $workspaceId, + 'alert_destination_id' => $destination->getKey(), + 'status' => AlertDelivery::STATUS_SENT, + ]); + + $component = Livewire::test(ViewAlertDestination::class, ['record' => $destination->getRouteKey()]); + + $url = $component->instance()->getAction('view_last_delivery')->getUrl(); + + expect($url)->toContain('alert-deliveries'); + expect($url)->toContain('filters%5Bevent_type%5D%5Bvalue%5D=alerts.test'); + expect($url)->toContain('filters%5Balert_destination_id%5D%5Bvalue%5D='.$destination->getKey()); +}); diff --git a/tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php b/tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php new file mode 100644 index 0000000..160ba24 --- /dev/null +++ b/tests/Feature/Badges/AlertDeliveryAndLastTestBadgeSemanticsTest.php @@ -0,0 +1,62 @@ +label)->toBe('Queued'); + expect($queued->color)->toBe('gray'); + + $deferred = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'deferred'); + expect($deferred->label)->toBe('Deferred'); + expect($deferred->color)->toBe('warning'); + + $sent = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'sent'); + expect($sent->label)->toBe('Sent'); + expect($sent->color)->toBe('success'); + + $failed = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'failed'); + expect($failed->label)->toBe('Failed'); + expect($failed->color)->toBe('danger'); + + $suppressed = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'suppressed'); + expect($suppressed->label)->toBe('Suppressed'); + expect($suppressed->color)->toBe('info'); + + $canceled = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'canceled'); + expect($canceled->label)->toBe('Canceled'); + expect($canceled->color)->toBe('gray'); +}); + +it('returns unknown badge for invalid alert delivery status', function (): void { + $unknown = BadgeCatalog::spec(BadgeDomain::AlertDeliveryStatus, 'invalid_status'); + expect($unknown->label)->toBe('Unknown'); + expect($unknown->color)->toBe('gray'); +}); + +it('maps alert destination last test status values to canonical badge semantics', function (): void { + $never = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'never'); + expect($never->label)->toBe('Never'); + expect($never->color)->toBe('gray'); + + $sent = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'sent'); + expect($sent->label)->toBe('Sent'); + expect($sent->color)->toBe('success'); + + $failed = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'failed'); + expect($failed->label)->toBe('Failed'); + expect($failed->color)->toBe('danger'); + + $pending = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'pending'); + expect($pending->label)->toBe('Pending'); + expect($pending->color)->toBe('warning'); +}); + +it('returns unknown badge for invalid last test status', function (): void { + $unknown = BadgeCatalog::spec(BadgeDomain::AlertDestinationLastTestStatus, 'nonexistent'); + expect($unknown->label)->toBe('Unknown'); + expect($unknown->color)->toBe('gray'); +});