TenantAtlas/tests/Feature/Guards/ActionSurfaceContractTest.php
ahmido b1e1e06861 feat: implement finding risk acceptance lifecycle (#184)
## 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
2026-03-20 01:07:55 +00:00

408 lines
17 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\BackupScheduleResource;
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BaselineProfileResource;
use App\Filament\Resources\BaselineProfileResource\Pages\ListBaselineProfiles;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingExceptionResource\Pages\ListFindingExceptions;
use App\Filament\Resources\InventoryItemResource;
use App\Filament\Resources\InventoryItemResource\Pages\ListInventoryItems;
use App\Filament\Resources\OperationRunResource;
use App\Filament\Resources\PolicyResource;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
use App\Filament\Resources\Workspaces\WorkspaceResource;
use App\Jobs\SyncPoliciesJob;
use App\Models\BaselineProfile;
use App\Models\EvidenceSnapshot;
use App\Models\InventoryItem;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunLinks;
use App\Support\Ui\ActionSurface\ActionSurfaceExemptions;
use App\Support\Ui\ActionSurface\ActionSurfaceProfileDefinition;
use App\Support\Ui\ActionSurface\ActionSurfaceValidator;
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\WorkspaceIsolation\TenantOwnedModelFamilies;
use Filament\Actions\ActionGroup;
use Filament\Actions\BulkActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('passes the action surface contract guard for current repository state', function (): void {
$result = ActionSurfaceValidator::withBaselineExemptions()->validate();
expect($result->hasIssues())->toBeFalse($result->formatForAssertion());
});
it('excludes widgets from action surface discovery scope', function (): void {
$classes = array_map(
static fn ($component): string => $component->className,
ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents(),
);
$widgetClasses = array_values(array_filter($classes, static function (string $className): bool {
return str_starts_with($className, 'App\\Filament\\Widgets\\');
}));
expect($widgetClasses)->toBeEmpty();
});
it('keeps baseline exemptions explicit and does not auto-exempt unknown classes', function (): void {
$exemptions = ActionSurfaceExemptions::baseline();
expect($exemptions->hasClass('App\\Filament\\Resources\\ActionSurfaceUnknownResource'))->toBeFalse();
});
it('maps tenant/admin panel scope metadata from discovery sources', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$tenantResource = $components->get(\App\Filament\Resources\TenantResource::class);
$policyResource = $components->get(\App\Filament\Resources\PolicyResource::class);
expect($tenantResource)->not->toBeNull();
expect($tenantResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
expect($policyResource)->not->toBeNull();
expect($policyResource?->hasPanelScope(ActionSurfacePanelScope::Tenant))->toBeTrue();
});
it('requires non-empty reasons for every baseline exemption', function (): void {
$reasons = ActionSurfaceExemptions::baseline()->all();
foreach ($reasons as $className => $reason) {
expect(trim($reason))->not->toBe('', "Baseline exemption reason is empty for {$className}");
}
});
it('discovers the baseline profile resource and validates its declaration', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$baselineResource = $components->get(BaselineProfileResource::class);
expect($baselineResource)->not->toBeNull('BaselineProfileResource should be discovered by action surface discovery');
expect($baselineResource?->hasPanelScope(ActionSurfacePanelScope::Admin))->toBeTrue();
$declaration = BaselineProfileResource::actionSurfaceDeclaration();
$profiles = new ActionSurfaceProfileDefinition;
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
expect($declaration->slot($slot))
->not->toBeNull("Missing required slot {$slot->value} in BaselineProfileResource declaration");
}
});
it('keeps BaselineProfile archive under the More menu and declares it in the action surface slots', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$profile = BaselineProfile::factory()->active()->create([
'workspace_id' => (int) $tenant->workspace_id,
]);
$declaration = BaselineProfileResource::actionSurfaceDeclaration();
$details = $declaration->slot(ActionSurfaceSlot::ListRowMoreMenu)?->details;
expect($details)->toBeString();
expect($details)->toContain('archive');
$this->actingAs($user);
$livewire = Livewire::test(ListBaselineProfiles::class)
->assertCanSeeTableRecords([$profile]);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$moreGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
expect($moreGroup)->toBeInstanceOf(ActionGroup::class);
expect($moreGroup?->getLabel())->toBe('More');
$primaryRowActionNames = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($primaryRowActionNames)->toContain('view');
expect($primaryRowActionNames)->not->toContain('archive');
$primaryRowActionCount = count($primaryRowActionNames);
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
$moreActionNames = collect($moreGroup?->getActions())
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($moreActionNames)->toContain('archive');
});
it('ensures representative declarations satisfy required slots', function (): void {
$profiles = new ActionSurfaceProfileDefinition;
$declarations = [
AlertDeliveryResource::class => AlertDeliveryResource::actionSurfaceDeclaration(),
BackupScheduleResource::class => BackupScheduleResource::actionSurfaceDeclaration(),
BackupSetResource::class => BackupSetResource::actionSurfaceDeclaration(),
BaselineSnapshotResource::class => BaselineSnapshotResource::actionSurfaceDeclaration(),
EntraGroupResource::class => EntraGroupResource::actionSurfaceDeclaration(),
EvidenceSnapshotResource::class => EvidenceSnapshotResource::actionSurfaceDeclaration(),
FindingExceptionResource::class => FindingExceptionResource::actionSurfaceDeclaration(),
PolicyResource::class => PolicyResource::actionSurfaceDeclaration(),
OperationRunResource::class => OperationRunResource::actionSurfaceDeclaration(),
VersionsRelationManager::class => VersionsRelationManager::actionSurfaceDeclaration(),
BaselineProfileResource::class => BaselineProfileResource::actionSurfaceDeclaration(),
WorkspaceResource::class => WorkspaceResource::actionSurfaceDeclaration(),
];
foreach ($declarations as $className => $declaration) {
foreach ($profiles->requiredSlots($declaration->profile) as $slot) {
expect($declaration->slot($slot))
->not->toBeNull("Missing required slot {$slot->value} in declaration for {$className}");
}
}
});
it('requires every first-slice tenant-owned resource to be discovered without relying on baseline action-surface exemptions', function (): void {
$components = collect(ActionSurfaceValidator::withBaselineExemptions()->discoveredComponents())
->keyBy('className');
$baselineExemptions = ActionSurfaceExemptions::baseline();
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
$resourceClass = $family['resource'];
expect($components->has($resourceClass))
->toBeTrue("{$familyName} resource should be discoverable by the action-surface validator.");
$hasDeclaration = method_exists($resourceClass, 'actionSurfaceDeclaration');
$hasBaselineExemption = $baselineExemptions->hasClass($resourceClass);
expect($hasDeclaration || $hasBaselineExemption)
->toBeTrue("{$familyName} resource must either define actionSurfaceDeclaration() or carry an explicit baseline exemption.");
if ($hasDeclaration) {
expect($hasBaselineExemption)
->toBeFalse("{$familyName} resource should not keep a stale baseline exemption once actionSurfaceDeclaration() exists.");
continue;
}
expect(trim((string) $baselineExemptions->reasonForClass($resourceClass)))
->not->toBe('', "{$familyName} resource baseline exemption reason must stay explicit.");
}
});
it('keeps first-slice tenant-owned action-surface exemptions registry-backed and explicit', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline();
foreach (TenantOwnedModelFamilies::actionSurfaceBaselineExemptions() as $className => $reason) {
expect($baselineExemptions->reasonForClass($className))
->toBe($reason);
}
foreach (TenantOwnedModelFamilies::firstSlice() as $familyName => $family) {
if ($family['action_surface'] !== 'baseline_exemption') {
continue;
}
expect(trim($family['action_surface_reason']))
->not->toBe('', "{$familyName} baseline exemption reason must stay explicit in the registry.");
}
});
it('keeps first-slice trusted-state page action-surface status explicit', function (): void {
$baselineExemptions = ActionSurfaceExemptions::baseline();
expect($baselineExemptions->hasClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toBeTrue()
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\TenantRequiredPermissions::class))->toContain('dedicated tests');
expect($baselineExemptions->hasClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toBeTrue()
->and((string) $baselineExemptions->reasonForClass(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class))->toContain('dedicated conformance tests');
expect(method_exists(\App\Filament\System\Pages\Ops\Runbooks::class, 'actionSurfaceDeclaration'))->toBeFalse()
->and($baselineExemptions->hasClass(\App\Filament\System\Pages\Ops\Runbooks::class))->toBeFalse();
});
it('keeps finding exception v1 list exemptions explicit and omits grouped or bulk mutations', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$declaration = FindingExceptionResource::actionSurfaceDeclaration();
expect((string) ($declaration->exemption(ActionSurfaceSlot::ListRowMoreMenu)?->reason ?? ''))
->toContain('avoids a More menu');
expect((string) ($declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup)?->reason ?? ''))
->toContain('omit bulk actions');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$livewire = Livewire::test(ListFindingExceptions::class)
->assertTableEmptyStateActionsExistInOrder(['open_findings']);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
expect(collect($rowActions)->contains(static fn ($action): bool => $action instanceof ActionGroup))->toBeFalse();
expect(collect($rowActions)->map(static fn ($action): ?string => $action->getName())->filter()->values()->all())
->toEqualCanonicalizing(['renew_exception', 'revoke_exception']);
expect($table->getBulkActions())->toBeEmpty();
});
it('documents the guided alert delivery empty state without introducing a list-header CTA', function (): void {
$declaration = AlertDeliveryResource::actionSurfaceDeclaration();
expect((string) ($declaration->slot(ActionSurfaceSlot::ListEmptyState)?->details ?? ''))
->toContain('View alert rules');
});
it('uses More grouping conventions and exposes empty-state CTA on representative CRUD list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$livewire = Livewire::test(ListPolicies::class)
->assertTableEmptyStateActionsExistInOrder(['sync']);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$rowGroup = collect($rowActions)->first(static fn ($action): bool => $action instanceof ActionGroup);
expect($rowGroup)->toBeInstanceOf(ActionGroup::class);
expect($rowGroup?->getLabel())->toBe('More');
$primaryRowActionCount = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->count();
expect($primaryRowActionCount)->toBeLessThanOrEqual(2);
$bulkActions = $table->getBulkActions();
$bulkGroup = collect($bulkActions)->first(static fn ($action): bool => $action instanceof BulkActionGroup);
expect($bulkGroup)->toBeInstanceOf(BulkActionGroup::class);
expect($bulkGroup?->getLabel())->toBe('More');
});
it('keeps evidence snapshots on the declared clickable-row, two-action surface', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListEvidenceSnapshots::class)
->assertTableEmptyStateActionsExistInOrder(['create_first_snapshot']);
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 1, 'missing_dimensions' => 0],
'generated_at' => now(),
]);
$livewire = Livewire::test(ListEvidenceSnapshots::class)
->assertCanSeeTableRecords([$snapshot]);
$table = $livewire->instance()->getTable();
$rowActions = $table->getActions();
$primaryRowActionNames = collect($rowActions)
->reject(static fn ($action): bool => $action instanceof ActionGroup)
->map(static fn ($action): ?string => $action->getName())
->filter()
->values()
->all();
expect($primaryRowActionNames)->toBe(['view_snapshot', 'expire']);
expect($table->getBulkActions())->toBeEmpty();
expect($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
});
it('uses canonical tenantless View run links on representative operation links', function (): void {
$tenant = Tenant::factory()->create();
$run = OperationRun::factory()->create([
'tenant_id' => $tenant->getKey(),
'workspace_id' => $tenant->workspace_id,
]);
expect(OperationRunLinks::view($run, $tenant))
->toBe(route('admin.operations.view', ['run' => (int) $run->getKey()]));
});
it('removes lone View buttons and uses clickable rows on the inventory items list', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$item = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
]);
$livewire = Livewire::test(ListInventoryItems::class);
$table = $livewire->instance()->getTable();
expect($table->getActions())->toBeEmpty();
$recordUrl = $table->getRecordUrl($item);
expect($recordUrl)->not->toBeNull();
expect($recordUrl)->toBe(InventoryItemResource::getUrl('view', ['record' => $item]));
});
it('keeps representative operation-start actions observable with actor and scope metadata', function (): void {
Queue::fake();
bindFailHardGraphClient();
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class)
->mountAction('sync')
->callMountedAction()
->assertHasNoActionErrors();
Queue::assertPushed(SyncPoliciesJob::class);
$run = OperationRun::query()
->where('tenant_id', $tenant->getKey())
->where('type', 'policy.sync')
->latest('id')
->first();
expect($run)->not->toBeNull();
expect((int) $run?->tenant_id)->toBe((int) $tenant->getKey());
expect((int) $run?->workspace_id)->toBe((int) $tenant->workspace_id);
expect((string) $run?->initiator_name)->toBe((string) $user->name);
});