## Summary - add the Evidence Snapshot domain with immutable tenant-scoped snapshots, per-dimension items, queued generation, audit actions, badge mappings, and Filament list/detail surfaces - add the workspace evidence overview, capability and policy wiring, Livewire update-path hardening, and review-pack integration through explicit evidence snapshot resolution - add spec 153 artifacts, migrations, factories, and focused Pest coverage for evidence, review-pack reuse, authorization, action-surface regressions, and audit behavior ## Testing - `vendor/bin/sail artisan test --compact --stop-on-failure` - `CI=1 vendor/bin/sail artisan test --compact` - `vendor/bin/sail bin pint --dirty --format agent` ## Notes - branch: `153-evidence-domain-foundation` - commit: `b7dfa279` - spec: `specs/153-evidence-domain-foundation/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #183
379 lines
16 KiB
PHP
379 lines
16 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\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(),
|
|
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('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);
|
|
});
|