diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index bad0ae9..95ab893 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -7,6 +7,8 @@ use App\Models\BackupSet; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\Graph\GraphClientInterface; +use App\Services\Graph\GroupResolver; use App\Services\Intune\RestoreService; use BackedEnum; use Filament\Actions; @@ -15,10 +17,13 @@ use Filament\Infolists; use Filament\Notifications\Notification; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Support\Str; use UnitEnum; class RestoreRunResource extends Resource @@ -54,6 +59,10 @@ public static function form(Schema $schema): Schema }); }) ->reactive() + ->afterStateUpdated(function (Set $set): void { + $set('backup_item_ids', []); + $set('group_mapping', []); + }) ->required(), Forms\Components\CheckboxList::make('backup_item_ids') ->label('Items to restore (optional)') @@ -86,7 +95,57 @@ public static function form(Schema $schema): Schema }); }) ->columns(2) + ->reactive() + ->afterStateUpdated(fn (Set $set) => $set('group_mapping', [])) ->helperText('Preview-only types stay in dry-run; leave empty to include all items.'), + Section::make('Group mapping') + ->description('Some source groups do not exist in the target tenant. Map them or choose Skip.') + ->schema(function (Get $get): array { + $backupSetId = $get('backup_set_id'); + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return []; + } + + $unresolved = static::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, + tenant: $tenant + ); + + return array_map(function (array $group) use ($tenant): Forms\Components\Select { + $groupId = $group['id']; + $label = $group['label']; + + return Forms\Components\Select::make("group_mapping.{$groupId}") + ->label($label) + ->options([ + 'SKIP' => 'Skip assignment', + ]) + ->searchable() + ->getSearchResultsUsing(fn (string $search) => static::targetGroupOptions($tenant, $search)) + ->getOptionLabelUsing(fn ($value) => static::resolveTargetGroupLabel($tenant, $value)) + ->required() + ->helperText('Choose a target group or select Skip.'); + }, $unresolved); + }) + ->visible(function (Get $get): bool { + $backupSetId = $get('backup_set_id'); + $selectedItemIds = $get('backup_item_ids'); + $tenant = Tenant::current(); + + if (! $tenant || ! $backupSetId) { + return false; + } + + return static::unresolvedGroups( + backupSetId: $backupSetId, + selectedItemIds: is_array($selectedItemIds) ? $selectedItemIds : null, + tenant: $tenant + ) !== []; + }), Forms\Components\Toggle::make('is_dry_run') ->label('Preview only (dry-run)') ->default(true), @@ -233,6 +292,161 @@ public static function createRestoreRun(array $data): RestoreRun dryRun: (bool) ($data['is_dry_run'] ?? true), actorEmail: auth()->user()?->email, actorName: auth()->user()?->name, + groupMapping: $data['group_mapping'] ?? [], ); } + + /** + * @param array|null $selectedItemIds + * @return array + */ + private static function unresolvedGroups(?int $backupSetId, ?array $selectedItemIds, Tenant $tenant): array + { + if (! $backupSetId) { + return []; + } + + $query = BackupItem::query()->where('backup_set_id', $backupSetId); + + if ($selectedItemIds !== null) { + $query->whereIn('id', $selectedItemIds); + } + + $items = $query->get(['assignments']); + $assignments = []; + $sourceNames = []; + + foreach ($items as $item) { + if (! is_array($item->assignments) || $item->assignments === []) { + continue; + } + + foreach ($item->assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $target = $assignment['target'] ?? []; + $odataType = $target['@odata.type'] ?? ''; + + if (! in_array($odataType, [ + '#microsoft.graph.groupAssignmentTarget', + '#microsoft.graph.exclusionGroupAssignmentTarget', + ], true)) { + continue; + } + + $groupId = $target['groupId'] ?? null; + + if (! is_string($groupId) || $groupId === '') { + continue; + } + + $assignments[] = $groupId; + $displayName = $target['group_display_name'] ?? null; + + if (is_string($displayName) && $displayName !== '') { + $sourceNames[$groupId] = $displayName; + } + } + } + + $groupIds = array_values(array_unique($assignments)); + + if ($groupIds === []) { + return []; + } + + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + $resolved = app(GroupResolver::class)->resolveGroupIds($groupIds, $tenantIdentifier, $graphOptions); + + $unresolved = []; + + foreach ($groupIds as $groupId) { + $group = $resolved[$groupId] ?? null; + + if (! is_array($group) || ! ($group['orphaned'] ?? false)) { + continue; + } + + $label = static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId); + $unresolved[] = [ + 'id' => $groupId, + 'label' => $label, + ]; + } + + return $unresolved; + } + + /** + * @return array + */ + private static function targetGroupOptions(Tenant $tenant, string $search): array + { + if (mb_strlen($search) < 2) { + return []; + } + + try { + $response = app(GraphClientInterface::class)->request( + 'GET', + 'groups', + [ + 'query' => [ + '$filter' => sprintf( + "securityEnabled eq true and startswith(displayName,'%s')", + static::escapeOdataValue($search) + ), + '$select' => 'id,displayName', + '$top' => 20, + ], + ] + $tenant->graphOptions() + ); + } catch (\Throwable) { + return []; + } + + if ($response->failed()) { + return []; + } + + return collect($response->data['value'] ?? []) + ->filter(fn (array $group) => filled($group['id'] ?? null)) + ->mapWithKeys(fn (array $group) => [ + $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $group['id']), + ]) + ->all(); + } + + private static function resolveTargetGroupLabel(Tenant $tenant, ?string $groupId): ?string + { + if (! $groupId) { + return $groupId; + } + + if ($groupId === 'SKIP') { + return 'Skip assignment'; + } + + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + $resolved = app(GroupResolver::class)->resolveGroupIds([$groupId], $tenantIdentifier, $graphOptions); + $group = $resolved[$groupId] ?? null; + + return static::formatGroupLabel($group['displayName'] ?? null, $groupId); + } + + private static function formatGroupLabel(?string $displayName, string $id): string + { + $suffix = sprintf(' (%s)', Str::limit($id, 8, '')); + + return trim(($displayName ?: 'Security group').$suffix); + } + + private static function escapeOdataValue(string $value): string + { + return str_replace("'", "''", $value); + } } diff --git a/app/Jobs/RestoreAssignmentsJob.php b/app/Jobs/RestoreAssignmentsJob.php new file mode 100644 index 0000000..5bdec75 --- /dev/null +++ b/app/Jobs/RestoreAssignmentsJob.php @@ -0,0 +1,84 @@ +restoreRunId); + $tenant = Tenant::find($this->tenantId); + + if (! $restoreRun || ! $tenant) { + Log::warning('RestoreAssignmentsJob missing context', [ + 'restore_run_id' => $this->restoreRunId, + 'tenant_id' => $this->tenantId, + ]); + + return [ + 'outcomes' => [], + 'summary' => ['success' => 0, 'failed' => 0, 'skipped' => 0], + ]; + } + + try { + return $assignmentRestoreService->restore( + tenant: $tenant, + policyType: $this->policyType, + policyId: $this->policyId, + assignments: $this->assignments, + groupMapping: $this->groupMapping, + restoreRun: $restoreRun, + actorEmail: $this->actorEmail, + actorName: $this->actorName, + ); + } catch (\Throwable $e) { + Log::error('RestoreAssignmentsJob failed', [ + 'restore_run_id' => $this->restoreRunId, + 'policy_id' => $this->policyId, + 'error' => $e->getMessage(), + ]); + + return [ + 'outcomes' => [[ + 'status' => 'failed', + 'reason' => $e->getMessage(), + ]], + 'summary' => ['success' => 0, 'failed' => 1, 'skipped' => 0], + ]; + } + } +} diff --git a/app/Models/RestoreRun.php b/app/Models/RestoreRun.php index 90028e2..b729987 100644 --- a/app/Models/RestoreRun.php +++ b/app/Models/RestoreRun.php @@ -43,12 +43,16 @@ public function hasGroupMapping(): bool public function getMappedGroupId(string $sourceGroupId): ?string { - return $this->group_mapping[$sourceGroupId] ?? null; + $mapping = $this->group_mapping ?? []; + + return $mapping[$sourceGroupId] ?? null; } public function isGroupSkipped(string $sourceGroupId): bool { - return $this->group_mapping[$sourceGroupId] === 'SKIP'; + $mapping = $this->group_mapping ?? []; + + return ($mapping[$sourceGroupId] ?? null) === 'SKIP'; } public function getUnmappedGroupIds(array $sourceGroupIds): array @@ -66,7 +70,22 @@ public function addGroupMapping(string $sourceGroupId, string $targetGroupId): v // Assignment restore outcome helpers public function getAssignmentRestoreOutcomes(): array { - return $this->results['assignment_outcomes'] ?? []; + $results = $this->results ?? []; + + if (isset($results['assignment_outcomes']) && is_array($results['assignment_outcomes'])) { + return $results['assignment_outcomes']; + } + + if (! is_array($results)) { + return []; + } + + return collect($results) + ->pluck('assignment_outcomes') + ->flatten(1) + ->filter() + ->values() + ->all(); } public function getSuccessfulAssignmentsCount(): int diff --git a/app/Services/AssignmentRestoreService.php b/app/Services/AssignmentRestoreService.php new file mode 100644 index 0000000..fcb290f --- /dev/null +++ b/app/Services/AssignmentRestoreService.php @@ -0,0 +1,358 @@ +> $assignments + * @param array $groupMapping + * @return array{outcomes: array>, summary: array{success:int,failed:int,skipped:int}} + */ + public function restore( + Tenant $tenant, + string $policyType, + string $policyId, + array $assignments, + array $groupMapping, + ?RestoreRun $restoreRun = null, + ?string $actorEmail = null, + ?string $actorName = null, + ): array { + $outcomes = []; + $summary = [ + 'success' => 0, + 'failed' => 0, + 'skipped' => 0, + ]; + + if ($assignments === []) { + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + $contract = $this->contracts->get($policyType); + $listPath = $this->resolvePath($contract['assignments_list_path'] ?? null, $policyId); + $deletePathTemplate = $contract['assignments_delete_path'] ?? null; + $createPath = $this->resolvePath($contract['assignments_create_path'] ?? null, $policyId); + $createMethod = strtoupper((string) ($contract['assignments_create_method'] ?? 'POST')); + + if (! $listPath || ! $createPath || ! $deletePathTemplate) { + $outcomes[] = $this->failureOutcome(null, 'Assignments endpoints are not configured for this policy type.'); + $summary['failed']++; + + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + $graphOptions = $tenant->graphOptions(); + $tenantIdentifier = $graphOptions['tenant'] ?? $tenant->graphTenantId() ?? (string) $tenant->getKey(); + + $context = [ + 'tenant' => $tenantIdentifier, + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'restore_run_id' => $restoreRun?->id, + ]; + + $this->graphLogger->logRequest('restore_assignments_list', $context + [ + 'method' => 'GET', + 'endpoint' => $listPath, + ]); + + $response = $this->graphClient->request('GET', $listPath, $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_list', $response, $context + [ + 'method' => 'GET', + 'endpoint' => $listPath, + ]); + + $existingAssignments = $response->data['value'] ?? []; + + foreach ($existingAssignments as $existing) { + $assignmentId = $existing['id'] ?? null; + + if (! is_string($assignmentId) || $assignmentId === '') { + continue; + } + + $deletePath = $this->resolvePath($deletePathTemplate, $policyId, $assignmentId); + + if (! $deletePath) { + continue; + } + + $this->graphLogger->logRequest('restore_assignments_delete', $context + [ + 'method' => 'DELETE', + 'endpoint' => $deletePath, + 'assignment_id' => $assignmentId, + ]); + + $deleteResponse = $this->graphClient->request('DELETE', $deletePath, $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_delete', $deleteResponse, $context + [ + 'method' => 'DELETE', + 'endpoint' => $deletePath, + 'assignment_id' => $assignmentId, + ]); + + if ($deleteResponse->failed()) { + Log::warning('Failed to delete existing assignment during restore', $context + [ + 'assignment_id' => $assignmentId, + 'graph_error_message' => $deleteResponse->meta['error_message'] ?? null, + 'graph_error_code' => $deleteResponse->meta['error_code'] ?? null, + ]); + } + } + + foreach ($assignments as $assignment) { + if (! is_array($assignment)) { + continue; + } + + $groupId = $assignment['target']['groupId'] ?? null; + $mappedGroupId = $groupId && isset($groupMapping[$groupId]) ? $groupMapping[$groupId] : null; + + if ($mappedGroupId === 'SKIP') { + $outcomes[] = $this->skipOutcome($assignment, $groupId, $mappedGroupId); + $summary['skipped']++; + $this->logAssignmentOutcome( + status: 'skipped', + tenant: $tenant, + assignment: $assignment, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ] + ); + + continue; + } + + $assignmentToRestore = $this->applyGroupMapping($assignment, $mappedGroupId); + $assignmentToRestore = $this->sanitizeAssignment($assignmentToRestore); + + $this->graphLogger->logRequest('restore_assignments_create', $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]); + + $createResponse = $this->graphClient->request($createMethod, $createPath, [ + 'json' => $assignmentToRestore, + ] + $graphOptions); + + $this->graphLogger->logResponse('restore_assignments_create', $createResponse, $context + [ + 'method' => $createMethod, + 'endpoint' => $createPath, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]); + + if ($createResponse->successful()) { + $outcomes[] = $this->successOutcome($assignment, $groupId, $mappedGroupId); + $summary['success']++; + $this->logAssignmentOutcome( + status: 'created', + tenant: $tenant, + assignment: $assignment, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ] + ); + } else { + $outcomes[] = $this->failureOutcome( + $assignment, + $createResponse->meta['error_message'] ?? 'Graph create failed', + $groupId, + $mappedGroupId, + $createResponse + ); + $summary['failed']++; + $this->logAssignmentOutcome( + status: 'failed', + tenant: $tenant, + assignment: $assignment, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + metadata: [ + 'policy_id' => $policyId, + 'policy_type' => $policyType, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + 'graph_error_message' => $createResponse->meta['error_message'] ?? null, + 'graph_error_code' => $createResponse->meta['error_code'] ?? null, + ], + ); + } + + usleep(100000); + } + + return [ + 'outcomes' => $outcomes, + 'summary' => $summary, + ]; + } + + private function resolvePath(?string $template, string $policyId, ?string $assignmentId = null): ?string + { + if (! is_string($template) || $template === '') { + return null; + } + + $path = str_replace('{id}', urlencode($policyId), $template); + + if ($assignmentId !== null) { + $path = str_replace('{assignmentId}', urlencode($assignmentId), $path); + } + + return $path; + } + + private function applyGroupMapping(array $assignment, ?string $mappedGroupId): array + { + if (! $mappedGroupId) { + return $assignment; + } + + $target = $assignment['target'] ?? []; + $odataType = $target['@odata.type'] ?? ''; + + if (in_array($odataType, [ + '#microsoft.graph.groupAssignmentTarget', + '#microsoft.graph.exclusionGroupAssignmentTarget', + ], true) && isset($target['groupId'])) { + $target['groupId'] = $mappedGroupId; + $assignment['target'] = $target; + } + + return $assignment; + } + + private function sanitizeAssignment(array $assignment): array + { + $assignment = Arr::except($assignment, ['id']); + $target = $assignment['target'] ?? []; + + unset( + $target['group_display_name'], + $target['group_orphaned'], + $target['assignment_filter_name'] + ); + + $assignment['target'] = $target; + + return $assignment; + } + + private function successOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array + { + return [ + 'status' => 'success', + 'assignment' => $this->sanitizeAssignment($assignment), + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]; + } + + private function skipOutcome(array $assignment, ?string $groupId, ?string $mappedGroupId): array + { + return [ + 'status' => 'skipped', + 'assignment' => $this->sanitizeAssignment($assignment), + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + ]; + } + + private function failureOutcome( + ?array $assignment, + string $reason, + ?string $groupId = null, + ?string $mappedGroupId = null, + ?GraphResponse $response = null + ): array { + return array_filter([ + 'status' => 'failed', + 'assignment' => $assignment ? $this->sanitizeAssignment($assignment) : null, + 'group_id' => $groupId, + 'mapped_group_id' => $mappedGroupId, + 'reason' => $reason, + 'graph_error_message' => $response?->meta['error_message'] ?? null, + 'graph_error_code' => $response?->meta['error_code'] ?? null, + 'graph_request_id' => $response?->meta['request_id'] ?? null, + 'graph_client_request_id' => $response?->meta['client_request_id'] ?? null, + ], static fn ($value) => $value !== null); + } + + private function logAssignmentOutcome( + string $status, + Tenant $tenant, + array $assignment, + ?RestoreRun $restoreRun, + ?string $actorEmail, + ?string $actorName, + array $metadata + ): void { + $action = match ($status) { + 'created' => 'restore.assignment.created', + 'failed' => 'restore.assignment.failed', + default => 'restore.assignment.skipped', + }; + + $statusLabel = match ($status) { + 'created' => 'success', + 'failed' => 'failed', + default => 'warning', + }; + + $this->auditLogger->log( + tenant: $tenant, + action: $action, + context: [ + 'metadata' => $metadata, + 'assignment' => $this->sanitizeAssignment($assignment), + ], + actorEmail: $actorEmail, + actorName: $actorName, + status: $statusLabel, + resourceType: 'restore_run', + resourceId: $restoreRun ? (string) $restoreRun->id : null + ); + } +} diff --git a/app/Services/Intune/RestoreService.php b/app/Services/Intune/RestoreService.php index 726f7ce..71a8210 100644 --- a/app/Services/Intune/RestoreService.php +++ b/app/Services/Intune/RestoreService.php @@ -7,6 +7,7 @@ use App\Models\Policy; use App\Models\RestoreRun; use App\Models\Tenant; +use App\Services\AssignmentRestoreService; use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphContractRegistry; use App\Services\Graph\GraphErrorMapper; @@ -25,6 +26,7 @@ public function __construct( private readonly VersionService $versionService, private readonly SnapshotValidator $snapshotValidator, private readonly GraphContractRegistry $contracts, + private readonly AssignmentRestoreService $assignmentRestoreService, ) {} /** @@ -73,6 +75,7 @@ public function execute( bool $dryRun = true, ?string $actorEmail = null, ?string $actorName = null, + array $groupMapping = [], ): RestoreRun { $this->assertActiveContext($tenant, $backupSet); @@ -90,8 +93,28 @@ public function execute( 'preview' => $preview, 'started_at' => CarbonImmutable::now(), 'metadata' => [], + 'group_mapping' => $groupMapping !== [] ? $groupMapping : null, ]); + if ($groupMapping !== []) { + $this->auditLogger->log( + tenant: $tenant, + action: 'restore.group_mapping.applied', + context: [ + 'metadata' => [ + 'restore_run_id' => $restoreRun->id, + 'backup_set_id' => $backupSet->id, + 'mapped_groups' => count($groupMapping), + ], + ], + actorEmail: $actorEmail, + actorName: $actorName, + resourceType: 'restore_run', + resourceId: (string) $restoreRun->id, + status: 'success' + ); + } + $results = []; $hardFailures = 0; @@ -265,6 +288,31 @@ public function execute( continue; } + $assignmentOutcomes = null; + $assignmentSummary = null; + + if (! $dryRun && is_array($item->assignments) && $item->assignments !== []) { + $assignmentPolicyId = $createdPolicyId ?? $item->policy_identifier; + + $assignmentOutcomes = $this->assignmentRestoreService->restore( + tenant: $tenant, + policyType: $item->policy_type, + policyId: $assignmentPolicyId, + assignments: $item->assignments, + groupMapping: $groupMapping, + restoreRun: $restoreRun, + actorEmail: $actorEmail, + actorName: $actorName, + ); + + $assignmentSummary = $assignmentOutcomes['summary'] ?? null; + + if (is_array($assignmentSummary) && ($assignmentSummary['failed'] ?? 0) > 0 && $itemStatus === 'applied') { + $itemStatus = 'partial'; + $resultReason = 'Assignments restored with failures'; + } + } + $result = $context + ['status' => $itemStatus]; if ($settingsApply !== null) { @@ -285,6 +333,14 @@ public function execute( $result['reason'] = 'Some settings require attention'; } + if ($assignmentOutcomes !== null) { + $result['assignment_outcomes'] = $assignmentOutcomes['outcomes'] ?? []; + } + + if ($assignmentSummary !== null) { + $result['assignment_summary'] = $assignmentSummary; + } + $results[] = $result; $appliedPolicyId = $item->policy_identifier; diff --git a/tests/Feature/RestoreAssignmentApplicationTest.php b/tests/Feature/RestoreAssignmentApplicationTest.php new file mode 100644 index 0000000..b8d529b --- /dev/null +++ b/tests/Feature/RestoreAssignmentApplicationTest.php @@ -0,0 +1,250 @@ + + */ + public array $requestCalls = []; + + /** + * @param array $requestResponses + */ + public function __construct( + private readonly GraphResponse $applyPolicyResponse, + private array $requestResponses = [], + ) {} + + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, ['payload' => []]); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return $this->applyPolicyResponse; + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, []); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + $this->requestCalls[] = [ + 'method' => strtoupper($method), + 'path' => $path, + 'payload' => $options['json'] ?? null, + ]; + + return array_shift($this->requestResponses) ?? new GraphResponse(true, []); + } +} + +test('restore applies assignments with mapped groups', function () { + $applyResponse = new GraphResponse(true, []); + $requestResponses = [ + new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list + new GraphResponse(true, [], 204), // delete + new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1 + new GraphResponse(true, ['id' => 'assign-new-2'], 201), // create 2 + ]; + + $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'scp-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog Alpha', + '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, + 'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + 'group_display_name' => 'Source One', + ], + ], + [ + 'id' => 'assignment-2', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-2', + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + 'source-group-2' => 'target-group-2', + ], + ); + + $summary = $run->results[0]['assignment_summary'] ?? null; + + expect($summary)->not->toBeNull(); + expect($summary['success'])->toBe(2); + expect($summary['failed'])->toBe(0); + + $postCalls = collect($client->requestCalls) + ->filter(fn (array $call) => $call['method'] === 'POST') + ->values(); + + expect($postCalls)->toHaveCount(2); + expect($postCalls[0]['payload']['target']['groupId'])->toBe('target-group-1'); + expect($postCalls[0]['payload'])->not->toHaveKey('id'); +}); + +test('restore handles assignment failures gracefully', function () { + $applyResponse = new GraphResponse(true, []); + $requestResponses = [ + new GraphResponse(true, ['value' => [['id' => 'assign-old-1']]]), // list + new GraphResponse(true, [], 204), // delete + new GraphResponse(true, ['id' => 'assign-new-1'], 201), // create 1 + new GraphResponse(false, ['error' => ['message' => 'Bad request']], 400, [ + ['code' => 'BadRequest', 'message' => 'Bad request'], + ], [], [ + 'error_code' => 'BadRequest', + 'error_message' => 'Bad request', + ]), // create 2 fails + ]; + + $client = new RestoreAssignmentGraphClient($applyResponse, $requestResponses); + app()->instance(GraphClientInterface::class, $client); + + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-1', + 'name' => 'Tenant One', + 'metadata' => [], + ]); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'scp-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog Alpha', + '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, + 'payload' => ['id' => $policy->external_id, '@odata.type' => '#microsoft.graph.deviceManagementConfigurationPolicy'], + 'assignments' => [ + [ + 'id' => 'assignment-1', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-1', + ], + ], + [ + 'id' => 'assignment-2', + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'source-group-2', + ], + ], + ], + ]); + + $user = User::factory()->create(['email' => 'tester@example.com']); + $this->actingAs($user); + + $service = app(RestoreService::class); + $run = $service->execute( + tenant: $tenant, + backupSet: $backupSet, + selectedItemIds: [$backupItem->id], + dryRun: false, + actorEmail: $user->email, + actorName: $user->name, + groupMapping: [ + 'source-group-1' => 'target-group-1', + 'source-group-2' => 'target-group-2', + ], + ); + + $summary = $run->results[0]['assignment_summary'] ?? null; + + expect($summary)->not->toBeNull(); + expect($summary['success'])->toBe(1); + expect($summary['failed'])->toBe(1); + expect($run->results[0]['status'])->toBe('partial'); +}); diff --git a/tests/Feature/RestoreGroupMappingTest.php b/tests/Feature/RestoreGroupMappingTest.php new file mode 100644 index 0000000..f746750 --- /dev/null +++ b/tests/Feature/RestoreGroupMappingTest.php @@ -0,0 +1,168 @@ + '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, + '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); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + 'backup_item_ids' => [$backupItem->id], + ]) + ->assertFormFieldVisible('group_mapping.source-group-1'); +}); + +test('restore wizard persists group mapping selections', 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, + '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(function (string $id) { + $resolved = $id === 'target-group-1'; + + return [$id => [ + 'id' => $id, + 'displayName' => $resolved ? 'Target Group' : null, + 'orphaned' => ! $resolved, + ]]; + }) + ->all(); + }); + }); + + $user = User::factory()->create(); + $this->actingAs($user); + + Livewire::test(CreateRestoreRun::class) + ->fillForm([ + 'backup_set_id' => $backupSet->id, + 'backup_item_ids' => [$backupItem->id], + 'group_mapping' => [ + 'source-group-1' => 'target-group-1', + ], + 'is_dry_run' => true, + ]) + ->call('create') + ->assertHasNoFormErrors(); + + $run = RestoreRun::first(); + + expect($run)->not->toBeNull(); + expect($run->group_mapping)->toBe([ + 'source-group-1' => 'target-group-1', + ]); + + $this->assertDatabaseHas('audit_logs', [ + 'tenant_id' => $tenant->id, + 'action' => 'restore.group_mapping.applied', + ]); +});