TenantAtlas/apps/platform/tests/Feature/Filament/Spec332ProductProcessFlowSystemTest.php
Ahmed Darrazi 5773ad582c
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m5s
feat: finalize restore create ux productization
2026-05-28 23:58:31 +02:00

619 lines
25 KiB
PHP

<?php
declare(strict_types=1);
use App\Filament\Resources\RestoreRunResource;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Filament\Resources\RestoreRunResource\Presenters\RestoreRunCreatePresenter;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\EntraGroup;
use App\Models\ManagedEnvironment;
use App\Models\Policy;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function spec332ProductProcessFlowTenant(): array
{
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft');
return [$user, $tenant];
}
function spec332UsableBackupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-policy-usable',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Device Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Usable Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [
'displayName' => 'Spec332 Device Policy',
'settings' => ['foo' => 'bar'],
],
'metadata' => [
'displayName' => 'Spec332 Device Policy',
],
'assignments' => [],
]);
return [$backupSet, $backupItem];
}
function spec332EmptyBackupFixture(ManagedEnvironment $tenant): BackupSet
{
return BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Empty Backup',
'status' => 'completed',
'item_count' => 0,
]);
}
function spec332MetadataOnlyBackupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-policy-metadata-only',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Metadata Only Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Metadata-only Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [],
'metadata' => [
'displayName' => 'Spec332 Metadata Only Policy',
'snapshot_source' => 'metadata_only',
'warnings' => ['metadata only fallback'],
],
'assignments' => [],
]);
return [$backupSet, $backupItem];
}
function spec332UnresolvedGroupBackupFixture(ManagedEnvironment $tenant): array
{
$policy = Policy::create([
'managed_environment_id' => (int) $tenant->getKey(),
'external_id' => 'spec332-policy-group',
'policy_type' => 'deviceConfiguration',
'display_name' => 'Spec332 Group Mapping Policy',
'platform' => 'windows',
]);
$backupSet = BackupSet::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'name' => 'Spec332 Group Backup',
'status' => 'completed',
'item_count' => 1,
]);
$backupItem = BackupItem::factory()->create([
'managed_environment_id' => (int) $tenant->getKey(),
'backup_set_id' => (int) $backupSet->getKey(),
'policy_id' => (int) $policy->getKey(),
'policy_identifier' => $policy->external_id,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'payload' => [
'displayName' => 'Spec332 Group Mapping Policy',
],
'metadata' => [
'displayName' => 'Spec332 Group Mapping Policy',
],
'assignments' => [[
'target' => [
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
'groupId' => '11111111-1111-1111-1111-111111111111',
'group_display_name' => 'Spec332 Missing Group',
],
]],
]);
return [$backupSet, $backupItem];
}
function spec332WizardComponent($user, ManagedEnvironment $tenant): \Livewire\Features\SupportTesting\Testable
{
setAdminPanelContext($tenant);
return Livewire::actingAs($user)->test(CreateRestoreRun::class);
}
function spec332CurrentPreviewData(BackupSet $backupSet, BackupItem $backupItem): array
{
/** @var RestoreSafetyResolver $resolver */
$resolver = app(RestoreSafetyResolver::class);
$data = [
'backup_set_id' => (int) $backupSet->getKey(),
'scope_mode' => 'selected',
'backup_item_ids' => [(int) $backupItem->getKey()],
'is_dry_run' => true,
'group_mapping' => [],
'check_summary' => ['blocking' => 0, 'warning' => 0, 'safe' => 1],
'check_results' => [['code' => 'safe', 'severity' => 'safe']],
'checks_ran_at' => now('UTC')->toIso8601String(),
'preview_summary' => [
'generated_at' => now('UTC')->toIso8601String(),
'policies_total' => 1,
'policies_changed' => 0,
'assignments_changed' => 0,
'scope_tags_changed' => 0,
'raw_payload_marker' => 'spec332 raw payload should stay hidden',
],
'preview_diffs' => [[
'policy_identifier' => 'spec332-policy-usable',
'display_name' => 'Spec332 Device Policy',
'policy_type' => 'deviceConfiguration',
'platform' => 'windows',
'action' => 'update',
'assignments_changed' => false,
'scope_tags_changed' => false,
'diff' => [
'summary' => ['added' => 0, 'removed' => 0, 'changed' => 0],
'changed' => [],
'added' => [],
'removed' => [],
],
]],
'preview_ran_at' => now('UTC')->toIso8601String(),
];
$data['check_basis'] = $resolver->checksBasisFromData($data);
$data['preview_basis'] = $resolver->previewBasisFromData($data);
return RestoreRunResource::synchronizeRestoreSafetyDraft($data);
}
it('keeps only the reconciled spec 332 directory under the active product process flow path', function (): void {
$spec332Directories = collect(glob(repo_path('specs/332-*')) ?: [])
->map(static fn (string $path): string => basename($path))
->sort()
->values()
->all();
expect($spec332Directories)->toBe([
'332-product-process-flow-system-v1',
])
->and(is_dir(repo_path('specs/332-product-process-flow-system-v1')))->toBeTrue()
->and(is_dir(repo_path('specs/332-restore-run-preview-productization')))->toBeFalse()
->and((string) file_get_contents(repo_path('specs/332-product-process-flow-system-v1/spec.md')))
->toContain('Product Process Flow System v1')
->toContain('Spec 332 was reconciled from the narrower `specs/332-restore-run-preview-productization` path');
});
it('renders compact safety evidence on step 1 for a usable backup source', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UsableBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->assertSee('Restore Safety')
->assertSee('Backup quality summary')
->assertSee('Safety evidence')
->assertSee('View safety gates and proof')
->assertSee('View quality caveat and detail')
->assertSee('Restore safety gates')
->assertSee('Restore Proof')
->assertSee('Diagnostics - Collapsed')
->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.')
->assertSee('A usable source backup is selected for this restore draft.')
->assertSee('Continue to scope.')
->assertSee('Validate impact.')
->assertSee('This create flow does not prove recoverability before execution and post-run evidence exist.')
->assertDontSee('Technical startability')
->assertDontSee('write-gate')
->assertDontSee('hard-blocker')
->assertDontSee('Is this dangerous?')
->assertDontSee('tenant-wide recoverability')
->assertSeeHtml('data-step-label="Usable source selected"')
->assertSeeHtml('data-proof-label="Operation proof"');
expect($component->html())
->toContain('data-testid="restore-run-safety-evidence"')
->not->toContain('data-testid="restore-run-process-flow-full"');
});
it('does not mark usable source as complete when the backup has no captured items', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
$backupSet = spec332EmptyBackupFixture($tenant);
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->assertSee('Input quality signals do not prove that execution is safe or that recovery is verified.')
->assertSeeHtml('data-step-label="Usable source selected"')
->assertSeeHtml('data-step-status="required"');
});
it('renders compact restore evidence on step 2 without a separate proof rail', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UsableBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertSee('Restore evidence')
->assertSee('3/7 gates complete')
->assertSee('View validation gates and restore proof')
->assertSee('Requested by')
->assertSee('Diagnostics - Collapsed')
->assertSeeHtml('data-testid="restore-run-safety-evidence"');
expect($component->html())->toContain('data-testid="restore-run-safety-evidence"');
});
it('keeps group mapping details collapsed by default on step 2 until the resolver is opened explicitly', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertSee('Scope summary')
->assertSee('1 mapping required')
->assertSee('Validation blocked')
->assertSee('Resolve target mappings')
->assertSee('Restore evidence')
->assertSee('View validation gates and restore proof')
->assertSee('0 of 1 mappings resolved')
->assertSee('1 unresolved')
->assertSee('0 skipped')
->assertSee('Resolve required mappings before validation can run.')
->assertSee('Target mapping requirements')
->assertSee('Select a target group from the directory cache or enter a target group object ID as a fallback. Required mappings must be resolved before validation can run.')
->assertDontSee('Paste the target Entra ID group Object ID (GUID).');
$html = html_entity_decode($component->html(), ENT_QUOTES | ENT_HTML5);
expect(preg_match('/isCollapsed:\s*true[\s\S]{0,2200}Resolve target mappings/', $html))->toBe(1);
});
it('shows cached target group identity in mapping helper text when a cached target is selected', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
$targetGroupId = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
EntraGroup::factory()->for($tenant)->create([
'entra_id' => $targetGroupId,
'display_name' => 'Spec332 Cached Target Group',
]);
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->fillForm([
'group_mapping' => [
$sourceGroupId => $targetGroupId,
],
])
->assertSee('Target group: Spec332 Cached Target Group')
->assertSee('Target ID:')
->assertSee($targetGroupId);
});
it('labels manual GUID mapping as a manual fallback and counts it in the resolver summary', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
$targetGroupId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->fillForm([
'group_mapping' => [
$sourceGroupId => $targetGroupId,
],
])
->assertSee('1 of 1 mappings resolved')
->assertSee('0 unresolved')
->assertSee('0 skipped')
->assertSee('1 manual fallback')
->assertSee('Manual fallback target object ID')
->assertSee('Badge: Manual fallback');
});
it('does not treat invalid GUID values as resolved in the resolver summary', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->fillForm([
'group_mapping' => [
$sourceGroupId => 'not-a-guid',
],
])
->assertSee('0 of 1 mappings resolved')
->assertSee('1 unresolved')
->assertSee('Invalid group object ID (GUID).');
});
it('supports skipping and undoing a mapping assignment on step 2', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$sourceGroupId = '11111111-1111-1111-1111-111111111111';
$sourceGroupToken = '11111111_1111_1111_1111_111111111111';
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertFormComponentActionVisible("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}")
->callFormComponentAction("group_mapping.{$sourceGroupId}", "skip_assignment_{$sourceGroupToken}")
->assertSet("data.group_mapping.{$sourceGroupId}", 'SKIP')
->assertFormFieldHidden("group_mapping.{$sourceGroupId}")
->assertFormFieldVisible("group_mapping_skipped_{$sourceGroupToken}")
->assertSee('1 skipped')
->assertSee('This assignment will not be restored.')
->assertFormComponentActionVisible("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}")
->callFormComponentAction("group_mapping_skipped_{$sourceGroupToken}", "undo_skip_assignment_{$sourceGroupToken}")
->assertSet("data.group_mapping.{$sourceGroupId}", null)
->assertFormFieldVisible("group_mapping.{$sourceGroupId}");
expect($component)->not->toBeNull();
});
it('blocks progression out of step 2 while required mappings are unresolved', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet] = spec332UnresolvedGroupBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2);
$component
->goToNextWizardStep()
->assertWizardCurrentStep(2)
->assertNotified('Mappings required')
->assertSee('Resolve required mappings before validation can run.');
});
it('productizes the empty target group picker when step 2 needs dependency mapping', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet, $backupItem] = spec332UnresolvedGroupBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->assertWizardCurrentStep(2);
$component
->fillForm([
'scope_mode' => 'selected',
'backup_item_ids' => [(int) $backupItem->getKey()],
]);
/** @var \Filament\Schemas\Schema $schema */
$schema = $component->instance()->form;
$field = $schema->getComponentByStatePath('group_mapping.11111111-1111-1111-1111-111111111111');
$action = collect($field?->getSuffixActions() ?? [])
->first(fn ($candidate) => $candidate->getName() === 'select_from_directory_cache_11111111_1111_1111_1111_111111111111');
expect($field)->not->toBeNull()
->and($action)->not->toBeNull()
->and($action->getModalHeading())->toBe('Resolve target group mapping');
});
it('shows a product-safe blocked state for checks when provider credentials are missing', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false);
[$backupSet] = spec332UsableBackupFixture($tenant);
$component = spec332WizardComponent($user, $tenant)
->fillForm([
'backup_set_id' => (int) $backupSet->getKey(),
])
->goToNextWizardStep()
->goToNextWizardStep()
->assertWizardCurrentStep(3)
->assertSee('Validation decision')
->assertSee('Validation blocked')
->assertSee('Provider access must be repaired before restore checks can run.')
->assertSee('Provider credentials are not available for this environment.')
->assertSee('Restore checks cannot run until the provider connection is repaired.')
->assertSee('Repair the provider connection before validation can run.')
->assertSee('Review provider connection')
->assertFormComponentActionDoesNotExist('check_results', 'run_restore_checks')
->assertDontSee('Provider credentials are missing');
$component
->goToNextWizardStep()
->assertWizardCurrentStep(3)
->assertNotified('Validation blocked');
expect($component->html())->not->toContain('Exception');
});
it('renders compact validation evidence on step 3 after checks pass', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
$data = spec332CurrentPreviewData($backupSet, $backupItem);
$component = spec332WizardComponent($user, $tenant)
->set('data', $data)
->goToWizardStep(3)
->assertWizardCurrentStep(3)
->assertSee('Validation decision')
->assertSee('Validation passed')
->assertSee('Blockers')
->assertSee('Warnings')
->assertSee('Safe')
->assertSee('View 1 safe check detail')
->assertSee('Validation evidence')
->assertSee('View validation gates and restore proof')
->assertSee('Checks are current for this scope. Rerun only after scope or mapping changes.')
->assertDontSee('No checks have been recorded for this scope yet.')
->assertDontSee('Run checks after defining scope and mapping missing groups.');
$html = $component->html();
expect($html)
->toContain('data-testid="restore-run-safe-checks-details"')
->toContain('data-testid="restore-run-safety-evidence"');
});
it('keeps preview decision-first while showing compact safety status and restore proof', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
$data = spec332CurrentPreviewData($backupSet, $backupItem);
$component = spec332WizardComponent($user, $tenant)
->set('data', $data)
->goToWizardStep(4)
->assertWizardCurrentStep(4)
->assertSee('Review the preview and continue to confirmation.')
->assertDontSee('Review prerequisites before execution.')
->assertSee('Preview evidence')
->assertSee('View safety gates and restore proof')
->assertSee('Confirmation')
->assertSee('Restore Proof')
->assertSee('Operation proof')
->assertSee('Post-run evidence')
->assertSee('Diagnostics - Collapsed')
->assertDontSee('spec332 raw payload should stay hidden')
->assertDontSee('tenant-wide recovery is proven');
$html = $component->html();
expect($html)
->toContain('data-testid="restore-run-preview-evidence"');
expect(preg_match('/<details[^>]*data-testid="restore-run-preview-evidence-details"[^>]*>/', $html, $matches))->toBe(1);
expect($matches[0])->not->toContain(' open');
});
it('keeps confirm step locked when execution prerequisites are unavailable', function (): void {
$tenant = ManagedEnvironment::factory()->create([
'rbac_status' => 'ok',
'rbac_last_checked_at' => now(),
]);
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
ensureDefaultProviderConnection($tenant, 'microsoft', ensureCredential: false);
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
$data = spec332CurrentPreviewData($backupSet, $backupItem);
$contract = RestoreRunCreatePresenter::contract($data, 4, true, $tenant, $user);
expect(data_get($contract, 'previewSummary.primaryNextActionLabel'))
->toBe('Review preview and continue to confirmation; resolve execution prerequisites before executing.')
->and(data_get($contract, 'wizard_gate.next_gate_label'))->toBe('Confirmation required')
->and(data_get($contract, 'wizard_gate.can_continue'))->toBeTrue()
->and(data_get($contract, 'wizard_gate.can_execute'))->toBeFalse();
$component = spec332WizardComponent($user, $tenant)
->set('data', $data)
->goToWizardStep(5)
->assertWizardCurrentStep(5)
->assertSee('Confirmation summary')
->assertSee('Execution prerequisites blocked')
->assertSee('Create preview-only run')
->assertSee('Create a preview-only run, or resolve execution prerequisites before queueing real execution.')
->assertSee('Execution')
->assertSee('Execution unavailable until prerequisites are resolved')
->assertSee('Restore execution is blocked until required prerequisites are available. Evidence does not exist yet.')
->assertSee('Recovery is not verified until post-run evidence exists.')
->assertFormFieldDisabled('is_dry_run');
expect($component->html())->not->toContain('Operation proof is complete');
});
it('shows confirm step readiness when execution prerequisites are healthy', function (): void {
[$user, $tenant] = spec332ProductProcessFlowTenant();
[$backupSet, $backupItem] = spec332UsableBackupFixture($tenant);
$data = spec332CurrentPreviewData($backupSet, $backupItem);
spec332WizardComponent($user, $tenant)
->set('data', $data)
->goToWizardStep(5)
->assertWizardCurrentStep(5)
->assertSee('Confirmation summary')
->assertSee('Preview-only run ready')
->assertSee('Create preview-only run')
->assertSee('Create a preview-only restore run.')
->assertSee('Execution')
->assertSee('Execution unavailable until confirmation')
->assertSee('Recovery is not verified until post-run evidence exists.')
->assertFormFieldEnabled('is_dry_run');
});