## Summary - add a first-class finding exception domain with request, approval, rejection, renewal, and revocation lifecycle support - add tenant-scoped exception register, finding governance surfaces, and a canonical workspace approval queue in Filament - add audit, badge, evidence, and review-pack integrations plus focused Pest coverage for workflow, authorization, and governance validity ## Validation - vendor/bin/sail bin pint --dirty --format agent - CI=1 vendor/bin/sail artisan test --compact - manual integrated-browser smoke test for the request-exception happy path, tenant register visibility, and canonical queue visibility ## Notes - Filament implementation remains on v5 with Livewire v4-compatible surfaces - canonical queue lives in the admin panel; provider registration stays in bootstrap/providers.php - finding exceptions stay out of global search in this rollout Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #184
130 lines
5.5 KiB
PHP
130 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
|
use App\Filament\Resources\FindingResource\Pages\ViewFinding;
|
|
use App\Models\AuditLog;
|
|
use App\Models\Finding;
|
|
use App\Models\FindingException;
|
|
use App\Services\Findings\FindingExceptionService;
|
|
use App\Support\Audit\AuditActionId;
|
|
use Filament\Facades\Filament;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Livewire\Livewire;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
it('requests and approves a finding exception through the finding view and canonical queue', function (): void {
|
|
[$requester, $tenant] = createUserWithTenant(role: 'owner');
|
|
$approver = \App\Models\User::factory()->create();
|
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
|
|
|
|
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
|
|
|
|
$this->actingAs($requester);
|
|
Filament::setTenant($tenant, true);
|
|
|
|
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
|
->callAction('request_exception', [
|
|
'owner_user_id' => (int) $requester->getKey(),
|
|
'request_reason' => 'Awaiting remediation window',
|
|
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
|
'expires_at' => now()->addDays(30)->toDateTimeString(),
|
|
])
|
|
->assertHasNoActionErrors();
|
|
|
|
$exception = FindingException::query()->where('finding_id', (int) $finding->getKey())->first();
|
|
|
|
expect($exception)->toBeInstanceOf(FindingException::class)
|
|
->and($exception?->status)->toBe(FindingException::STATUS_PENDING);
|
|
|
|
$this->actingAs($approver);
|
|
Filament::setCurrentPanel('admin');
|
|
Filament::setTenant(null, true);
|
|
Filament::bootCurrentPanel();
|
|
|
|
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
|
|
Livewire::withQueryParams([
|
|
'exception' => (int) $exception?->getKey(),
|
|
])
|
|
->test(FindingExceptionsQueue::class)
|
|
->assertActionVisible('approve_selected_exception')
|
|
->assertSee('Awaiting remediation window');
|
|
|
|
$pendingException = FindingException::query()->findOrFail((int) $exception?->getKey());
|
|
|
|
app(FindingExceptionService::class)->approve($pendingException, $approver, [
|
|
'effective_from' => now()->addHour()->toDateTimeString(),
|
|
'expires_at' => now()->addDays(30)->toDateTimeString(),
|
|
'approval_reason' => 'Approved with compensating controls',
|
|
]);
|
|
|
|
expect($exception?->fresh()?->status)->toBe(FindingException::STATUS_ACTIVE)
|
|
->and($finding->fresh()?->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::FindingExceptionRequested->value)
|
|
->where('resource_type', 'finding_exception')
|
|
->where('resource_id', (string) $exception?->getKey())
|
|
->exists())->toBeTrue()
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::FindingExceptionApproved->value)
|
|
->where('resource_type', 'finding_exception')
|
|
->where('resource_id', (string) $exception?->getKey())
|
|
->exists())->toBeTrue();
|
|
});
|
|
|
|
it('requests and rejects a finding exception while keeping the finding out of accepted risk', function (): void {
|
|
[$requester, $tenant] = createUserWithTenant(role: 'owner');
|
|
$approver = \App\Models\User::factory()->create();
|
|
createUserWithTenant(tenant: $tenant, user: $approver, role: 'owner');
|
|
|
|
$finding = Finding::factory()->for($tenant)->create(['status' => Finding::STATUS_NEW]);
|
|
|
|
$this->actingAs($requester);
|
|
Filament::setTenant($tenant, true);
|
|
|
|
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
|
->callAction('request_exception', [
|
|
'owner_user_id' => (int) $requester->getKey(),
|
|
'request_reason' => 'Awaiting vendor remediation timeline',
|
|
'review_due_at' => now()->addDays(7)->toDateTimeString(),
|
|
'expires_at' => now()->addDays(21)->toDateTimeString(),
|
|
])
|
|
->assertHasNoActionErrors();
|
|
|
|
$exception = FindingException::query()->where('finding_id', (int) $finding->getKey())->firstOrFail();
|
|
|
|
$this->actingAs($approver);
|
|
Filament::setCurrentPanel('admin');
|
|
Filament::setTenant(null, true);
|
|
Filament::bootCurrentPanel();
|
|
|
|
session()->put(\App\Support\Workspaces\WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
|
|
Livewire::withQueryParams([
|
|
'exception' => (int) $exception->getKey(),
|
|
])
|
|
->test(FindingExceptionsQueue::class)
|
|
->assertActionVisible('reject_selected_exception')
|
|
->assertSee('Awaiting vendor remediation timeline');
|
|
|
|
app(FindingExceptionService::class)->reject($exception->fresh(), $approver, [
|
|
'rejection_reason' => 'Remediation must be completed before acceptance is granted.',
|
|
]);
|
|
|
|
expect($exception->fresh()?->status)->toBe(FindingException::STATUS_REJECTED)
|
|
->and($finding->fresh()?->status)->toBe(Finding::STATUS_NEW)
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::FindingExceptionRequested->value)
|
|
->where('resource_type', 'finding_exception')
|
|
->where('resource_id', (string) $exception->getKey())
|
|
->exists())->toBeTrue()
|
|
->and(AuditLog::query()
|
|
->where('action', AuditActionId::FindingExceptionRejected->value)
|
|
->where('resource_type', 'finding_exception')
|
|
->where('resource_id', (string) $exception->getKey())
|
|
->exists())->toBeTrue();
|
|
});
|