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()], '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 the full product process flow 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('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 and resolve required mappings.') ->assertSee('Validate impact before execution.') ->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-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 safety status on step 2 while keeping restore proof visible', function (): void { [$user, $tenant] = spec332ProductProcessFlowTenant(); [$backupSet] = spec332UsableBackupFixture($tenant); $component = spec332WizardComponent($user, $tenant) ->fillForm([ 'backup_set_id' => (int) $backupSet->getKey(), ]) ->goToNextWizardStep() ->assertWizardCurrentStep(2) ->assertSee('Restore safety status') ->assertSee('2/7 gates complete') ->assertSee('View safety gates') ->assertSee('Restore Proof') ->assertSee('Requested by') ->assertSee('Diagnostics - Collapsed') ->assertSeeHtml('data-testid="restore-run-process-flow-compact"'); expect($component->html())->toContain('data-testid="restore-run-process-flow-compact"'); }); 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('Resolve mappings') ->assertSee('Resolve target mappings') ->assertSee('Restore safety status') ->assertSee('Restore Proof') ->assertSee('0 of 1 mappings resolved') ->assertSee('1 unresolved') ->assertSee('0 skipped') ->assertSee('Resolve required mappings before validation can run.') ->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: '.$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 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 blocked') ->assertSee('Provider credentials are not available for this environment.') ->assertSee('Restore checks cannot run until the provider connection is repaired.') ->assertSee('Review provider connection') ->assertDontSee('Provider credentials are missing'); $component ->goToNextWizardStep() ->assertWizardCurrentStep(3) ->assertNotified('Validation blocked'); expect($component->html())->not->toContain('Exception'); }); 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 complete confirmation before execution can be queued.') ->assertDontSee('Review prerequisites before execution.') ->assertSee('Restore safety status') ->assertSee('Next gate:') ->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'); }); 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); $component = spec332WizardComponent($user, $tenant) ->set('data', $data) ->goToWizardStep(5) ->assertWizardCurrentStep(5) ->assertSee('Confirmation summary') ->assertSee('Execution') ->assertSee('Unavailable') ->assertSee('Review prerequisites before execution.') ->assertSee('Restore execution is blocked until required prerequisites are healthy again. Evidence does not exist yet.') ->assertSee('Confirmation does not claim recovery.') ->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('Execution') ->assertSee('Available after confirmation') ->assertSee('Confirmation does not claim recovery.') ->assertFormFieldEnabled('is_dry_run'); });