TenantAtlas/tests/Feature/Evidence/EvidenceSnapshotResourceTest.php
ahmido 3c3daae405 feat: normalize operator outcome taxonomy (#186)
## Summary
- introduce a shared operator outcome taxonomy with semantic axes, severity bands, and next-action policy
- apply the taxonomy to operations, evidence/review completeness, baseline semantics, and restore semantics
- harden badge rendering, tenant-safe filtering/search behavior, and operator-facing summary/notification wording
- add the spec kit artifacts, reference documentation, and regression coverage for diagnostic-vs-primary state handling

## Testing
- focused Pest coverage for taxonomy registry and badge guardrails
- operations presentation and notification tests
- evidence, baseline, restore, and tenant-scope regression tests

## Notes
- Livewire v4.0+ compliance is preserved in the existing Filament v5 stack
- panel provider registration remains unchanged in bootstrap/providers.php
- no new globally searchable resource was added; adopted resources remain tenant-safe and out of global search where required
- no new destructive action family was introduced; existing actions keep their current authorization and confirmation behavior
- no new frontend asset strategy was introduced; existing deploy flow with filament:assets remains unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #186
2026-03-22 12:13:34 +00:00

346 lines
13 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ListEvidenceSnapshots;
use App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot;
use App\Jobs\GenerateEvidenceSnapshotJob;
use App\Models\EvidenceSnapshot;
use App\Models\EvidenceSnapshotItem;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\StoredReport;
use App\Models\Tenant;
use App\Support\Auth\Capabilities;
use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus;
use Filament\Actions\ActionGroup;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function seedEvidenceDomain(Tenant $tenant): void
{
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
'fingerprint' => hash('sha256', 'permission-'.$tenant->getKey()),
]);
StoredReport::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
'fingerprint' => hash('sha256', 'entra-'.$tenant->getKey()),
]);
Finding::factory()->count(2)->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
]);
Finding::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'finding_type' => Finding::FINDING_TYPE_DRIFT,
]);
OperationRun::factory()->forTenant($tenant)->create();
}
it('renders the evidence list page for an authorized user', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('index', tenant: $tenant))
->assertOk();
});
it('disables evidence global search while keeping the view page available', function (): void {
$reflection = new ReflectionClass(EvidenceSnapshotResource::class);
expect($reflection->getStaticPropertyValue('isGloballySearchable'))->toBeFalse()
->and(array_keys(EvidenceSnapshotResource::getPages()))->toContain('view');
});
it('returns 404 for non members on the evidence list route', function (): void {
$tenant = Tenant::factory()->create();
[$user] = createUserWithTenant(role: 'owner');
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('index', tenant: $tenant))
->assertNotFound();
});
it('returns 403 for tenant members without evidence view capability on the evidence list route', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
Gate::define(Capabilities::EVIDENCE_VIEW, fn (): bool => false);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('index', tenant: $tenant))
->assertForbidden();
});
it('disables create snapshot for readonly members', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListEvidenceSnapshots::class)
->assertActionVisible('create_snapshot')
->assertActionDisabled('create_snapshot');
});
it('queues snapshot generation from the list header action', function (): void {
Queue::fake();
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
seedEvidenceDomain($tenant);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListEvidenceSnapshots::class)
->callAction('create_snapshot')
->assertNotified();
expect(EvidenceSnapshot::query()->count())->toBe(1);
Queue::assertPushed(GenerateEvidenceSnapshotJob::class);
});
it('renders the view page for an active snapshot', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$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' => 2],
'generated_at' => now(),
]);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->assertOk();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_snapshot')
->assertActionVisible('expire_snapshot');
});
it('renders readable evidence dimension summaries and keeps raw json available', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$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' => 3,
'report_count' => 2,
'operation_count' => 1,
],
'generated_at' => now(),
]);
EvidenceSnapshotItem::query()->create([
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'dimension_key' => 'findings_summary',
'state' => EvidenceCompletenessState::Complete->value,
'required' => true,
'source_kind' => 'model_summary',
'summary_payload' => [
'count' => 3,
'open_count' => 2,
'severity_counts' => [
'critical' => 1,
'high' => 1,
'medium' => 1,
'low' => 0,
],
'entries' => [
[
'title' => 'Admin consent missing',
'severity' => 'critical',
'status' => 'open',
],
],
],
'sort_order' => 10,
]);
EvidenceSnapshotItem::query()->create([
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'dimension_key' => 'entra_admin_roles',
'state' => EvidenceCompletenessState::Complete->value,
'required' => true,
'source_kind' => 'stored_report',
'summary_payload' => [
'role_count' => 2,
'roles' => [
['display_name' => 'Global Administrator'],
['display_name' => 'Security Administrator'],
],
],
'sort_order' => 20,
]);
EvidenceSnapshotItem::query()->create([
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'dimension_key' => 'operations_summary',
'state' => EvidenceCompletenessState::Complete->value,
'required' => true,
'source_kind' => 'operation_rollup',
'summary_payload' => [
'operation_count' => 3,
'failed_count' => 0,
'partial_count' => 0,
'entries' => [
[
'type' => 'tenant.evidence.snapshot.generate',
'outcome' => 'pending',
'status' => 'running',
],
[
'type' => 'tenant.review_pack.generate',
'outcome' => 'succeeded',
'status' => 'completed',
],
[
'type' => 'backup_schedule_purge',
'outcome' => 'pending',
'status' => 'queued',
],
],
],
'sort_order' => 30,
]);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant))
->assertOk()
->assertSeeText('3 findings, 2 open.')
->assertSeeText('Open findings')
->assertSeeText('Admin consent missing')
->assertSeeText('2 privileged Entra roles captured.')
->assertSeeText('Global Administrator')
->assertSeeText('Evidence snapshot generation · In progress')
->assertSeeText('Review pack generation · Completed successfully')
->assertSeeText('Backup schedule purge · Queued for execution')
->assertDontSeeText('Tenant.evidence.snapshot.generate · Pending · Running')
->assertSeeText('Copy JSON');
});
it('hides expire actions for expired snapshots on list and detail surfaces', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Expired->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 2],
'generated_at' => now()->subDay(),
'expires_at' => now(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ListEvidenceSnapshots::class)
->assertCanSeeTableRecords([$snapshot])
->assertTableActionHidden('expire', $snapshot);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionHidden('expire_snapshot');
});
it('disables refresh and expire actions for readonly members on snapshot detail', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$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' => 2],
'generated_at' => now(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_snapshot')
->assertActionDisabled('refresh_snapshot')
->assertActionVisible('expire_snapshot')
->assertActionDisabled('expire_snapshot');
});
it('keeps evidence list actions within the declared action surface contract', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$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' => 2, 'missing_dimensions' => 0],
'generated_at' => now(),
]);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$livewire = Livewire::actingAs($user)
->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'])
->and($table->getBulkActions())->toBeEmpty()
->and($table->getRecordUrl($snapshot))->toBe(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot]));
});