diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index 93093e5..8959cc7 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -397,11 +397,7 @@ public static function getWizardSteps(): array $selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null; - $groupMapping = $get('group_mapping') ?? []; - $groupMapping = is_array($groupMapping) ? $groupMapping : []; - $groupMapping = collect($groupMapping) - ->map(fn ($value) => is_string($value) ? $value : null) - ->all(); + $groupMapping = static::normalizeGroupMapping($get('group_mapping')); $checker = app(RestoreRiskChecker::class); $outcome = $checker->check( @@ -1143,7 +1139,7 @@ public static function createRestoreRun(array $data): RestoreRun $actorEmail = auth()->user()?->email; $actorName = auth()->user()?->name; $isDryRun = (bool) ($data['is_dry_run'] ?? true); - $groupMapping = $data['group_mapping'] ?? []; + $groupMapping = static::normalizeGroupMapping($data['group_mapping'] ?? null); $checkSummary = $data['check_summary'] ?? null; $checkResults = $data['check_results'] ?? null; @@ -1386,6 +1382,76 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem return $unresolved; } + /** + * @return array + */ + private static function normalizeGroupMapping(mixed $mapping): array + { + if ($mapping instanceof \Illuminate\Contracts\Support\Arrayable) { + $mapping = $mapping->toArray(); + } + + if ($mapping instanceof \stdClass) { + $mapping = (array) $mapping; + } + + if (! is_array($mapping)) { + return []; + } + + $result = []; + + if (array_key_exists('group_mapping', $mapping)) { + $nested = $mapping['group_mapping']; + + if ($nested instanceof \Illuminate\Contracts\Support\Arrayable) { + $nested = $nested->toArray(); + } + + if ($nested instanceof \stdClass) { + $nested = (array) $nested; + } + + if (is_array($nested)) { + $mapping = $nested; + } + } + + foreach ($mapping as $key => $value) { + if (! is_string($key) || $key === '') { + continue; + } + + $sourceGroupId = str_starts_with($key, 'group_mapping.') + ? substr($key, strlen('group_mapping.')) + : $key; + + if ($sourceGroupId === '') { + continue; + } + + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if (is_array($value) || $value instanceof \stdClass) { + $value = (array) $value; + $value = $value['value'] ?? $value['id'] ?? null; + } + + if (is_string($value)) { + $value = trim($value); + $result[$sourceGroupId] = $value !== '' ? $value : null; + + continue; + } + + $result[$sourceGroupId] = null; + } + + return $result; + } + /** * @return array */ diff --git a/tests/Feature/RestoreRiskChecksWizardTest.php b/tests/Feature/RestoreRiskChecksWizardTest.php index 79e90e1..c878d84 100644 --- a/tests/Feature/RestoreRiskChecksWizardTest.php +++ b/tests/Feature/RestoreRiskChecksWizardTest.php @@ -129,3 +129,94 @@ ]); expect($run->metadata['check_summary']['blocking'] ?? null)->toBe(1); }); + +test('restore wizard treats skipped orphaned groups as a warning instead of a blocker', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog', + 'platform' => 'windows', + ]); + + $backupSet = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup', + 'status' => 'completed', + 'item_count' => 1, + ]); + + $backupItem = BackupItem::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $backupSet->id, + 'policy_id' => $policy->id, + 'policy_identifier' => $policy->external_id, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'payload' => ['id' => $policy->external_id], + 'assignments' => [[ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + 'group_display_name' => 'Source Group', + ], + 'intent' => 'apply', + ]], + ]); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds') + ->andReturnUsing(function (array $groupIds): array { + return collect($groupIds) + ->mapWithKeys(fn (string $id) => [$id => [ + 'id' => $id, + 'displayName' => null, + 'orphaned' => true, + ]]) + ->all(); + }); + }); + + $user = User::factory()->create(); + $this->actingAs($user); + + $component = Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + ]) + ->goToNextWizardStep() + ->fillForm([ + 'scope_mode' => 'selected', + 'backup_item_ids' => [$backupItem->id], + ]) + ->goToNextWizardStep() + ->set('data.group_mapping', (object) [ + 'source-group-1' => 'SKIP', + ]) + ->callFormComponentAction('check_results', 'run_restore_checks'); + + $summary = $component->get('data.check_summary'); + $results = $component->get('data.check_results'); + + expect($summary)->toBeArray(); + expect($summary['blocking'] ?? null)->toBe(0); + expect($summary['has_blockers'] ?? null)->toBeFalse(); + expect($summary['warning'] ?? null)->toBe(1); + + $assignmentCheck = collect($results)->firstWhere('code', 'assignment_groups'); + expect($assignmentCheck)->toBeArray(); + expect($assignmentCheck['severity'] ?? null)->toBe('warning'); + + $skippedGroups = $assignmentCheck['meta']['skipped'] ?? []; + expect($skippedGroups)->toBeArray(); + expect($skippedGroups[0]['id'] ?? null)->toBe('source-group-1'); +});